diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb1b2c7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI/CD - Build Backend Docker + +on: + push: + branches: + - main + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-backend-docker: + name: Build & Push Backend Container + runs-on: ubuntu-latest + steps: + - name: Checkout Backend Code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/jast-backend:latest diff --git a/core/api.py b/core/api.py index d47a476..88855a6 100644 --- a/core/api.py +++ b/core/api.py @@ -108,6 +108,13 @@ class TransactionIn(Schema): class SuccessResponse(Schema): success: bool +class UserUpdateIn(Schema): + username: str | None = None + email: str | None = None + first_name: str | None = None + last_name: str | None = None + password: str | None = None + # Endpoints @api.post("/auth/register", response=TokenOut) @@ -145,6 +152,34 @@ def login_view(request, payload: LoginIn): "user": user, } +@api.put("/auth/me", response=UserOut, auth=auth) +def update_current_user(request, payload: UserUpdateIn): + user = request.auth + + if payload.username and payload.username != user.username: + if User.objects.filter(username=payload.username).exclude(id=user.id).exists(): + raise HttpError(400, "Username already taken") + user.username = payload.username + + if payload.email is not None: + user.email = payload.email + if payload.first_name is not None: + user.first_name = payload.first_name + if payload.last_name is not None: + user.last_name = payload.last_name + if payload.password: + user.set_password(payload.password) + + user.save() + return user + + +@api.delete("/auth/me", response=SuccessResponse, auth=auth) +def delete_current_user(request): + user = request.auth + user.delete() + return {"success": True} + @api.post("/auth/refresh", response=RefreshOut) def refresh_token_view(request, payload: RefreshIn): diff --git a/core/forms.py b/core/forms.py deleted file mode 100644 index 552a1c5..0000000 --- a/core/forms.py +++ /dev/null @@ -1,26 +0,0 @@ -from django import forms -from django.contrib.auth.forms import UserCreationForm -from django.contrib.auth.models import User -from .models import Transaction - -class TransactionForm(forms.ModelForm): - class Meta: - model = Transaction - fields = ['title', 'amount', 'category', 'date'] - - -class CustomUserCreationForm(UserCreationForm): - first_name = forms.CharField(max_length=30, required=True, label="First Name") - last_name = forms.CharField(max_length=30, required=True, label="Last Name") - - class Meta(UserCreationForm.Meta): - model = User - fields = UserCreationForm.Meta.fields + ('first_name', 'last_name') - - def save(self, commit=True): - user = super().save(commit=False) - user.first_name = self.cleaned_data['first_name'] - user.last_name = self.cleaned_data['last_name'] - if commit: - user.save() - return user diff --git a/core/static/css/style.css b/core/static/css/style.css deleted file mode 100644 index 164e359..0000000 --- a/core/static/css/style.css +++ /dev/null @@ -1,391 +0,0 @@ -:root { - /* Gruvbox Dark Palette */ - --bg-main: #282828; /* bg0 */ - --bg-card: #3c3836; /* bg1 */ - --bg-card-hover: #504945; /* bg2 */ - --border: #504945; /* bg2 */ - --border-hover: #665c54; /* bg3 */ - --border-focus: #fabd2f; /* Gruvbox Yellow */ - - --text-main: #ebdbb2; /* fg1 */ - --text-heading: #fbf1c7; /* fg0 */ - --text-muted: #a89984; /* fg4 */ - - --primary: #fabd2f; /* Gruvbox Yellow */ - --primary-hover: #d79921; /* Gruvbox Dark Yellow */ - --primary-text: #282828; /* Dark contrast text for yellow buttons */ - - --income: #b8bb26; /* Gruvbox Green */ - --expense: #fb4934; /* Gruvbox Red */ - --danger: #fb4934; /* Gruvbox Red */ - --accent-blue: #83a598; /* Gruvbox Blue */ - - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 16px; - --shadow: 0 4px 20px rgba(0, 0, 0, 0.4); -} - -* { - box-sizing: border-box; -} - -body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - max-width: 920px; - margin: 0 auto; - padding: 24px 20px 60px 20px; - color: var(--text-main); - background-color: var(--bg-main); - line-height: 1.5; - -webkit-font-smoothing: antialiased; -} - -/* Headings & Links */ -h1, h2, h3, h4 { - margin-top: 0; - color: var(--text-heading); - font-weight: 700; - letter-spacing: -0.02em; -} - -a { - color: var(--primary); - text-decoration: none; - transition: color 0.15s ease; -} - -a:hover { - color: var(--primary-hover); -} - -/* Navbar */ -.navbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 0; - border-bottom: 1px solid var(--border); - margin-bottom: 32px; -} - -.navbar-brand { - font-size: 22px; - font-weight: 800; - letter-spacing: 0.05em; - text-decoration: none; - color: var(--primary); -} - -.navbar-actions { - display: flex; - align-items: center; - gap: 10px; -} - -/* Header Section & Balance Card */ -.header-section { - margin-bottom: 32px; - padding: 24px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow); -} - -.header-section h1 { - margin: 0 0 8px 0; - font-size: 28px; -} - -.balance-text { - margin: 0; - font-size: 15px; - color: var(--text-muted); -} - -.balance-text strong { - font-size: 24px; - color: var(--primary); - font-weight: 700; - margin-left: 6px; -} - -/* Cards & Auth Containers */ -.card { - background: var(--bg-card); - border: 1px solid var(--border); - padding: 24px; - border-radius: var(--radius-md); - margin-bottom: 32px; - box-shadow: var(--shadow); -} - -.auth-container { - max-width: 420px; - margin: 60px auto; -} - -.auth-container h2 { - text-align: center; - margin-bottom: 24px; -} - -.auth-container p { - text-align: center; - color: var(--text-muted); - font-size: 14px; -} - -/* Tables */ -table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - margin-top: 10px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-md); - overflow: hidden; -} - -th { - background: var(--bg-card-hover); - color: var(--text-muted); - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - text-align: left; -} - -td { - padding: 14px 16px; - border-bottom: 1px solid var(--border); - font-size: 14px; - color: var(--text-main); -} - -tr:last-child td { - border-bottom: none; -} - -tr:hover td { - background-color: var(--bg-card-hover); -} - -.income { - color: var(--income); - font-weight: 600; -} - -.expense { - color: var(--expense); - font-weight: 500; -} - -/* Filter Pills */ -.filters { - margin-bottom: 32px; - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.filters a { - padding: 8px 16px; - border-radius: var(--radius-sm); - text-decoration: none; - border: 1px solid var(--border); - background: var(--bg-card); - color: var(--text-muted); - font-size: 14px; - font-weight: 500; - transition: all 0.15s ease; -} - -.filters a:hover { - border-color: var(--border-hover); - color: var(--text-main); -} - -.filters a.active { - background: var(--primary); - color: var(--primary-text); - border-color: var(--primary); - font-weight: 600; - box-shadow: 0 0 12px rgba(250, 189, 47, 0.25); -} - -/* Chart Container */ -.chart-container { - position: relative; - height: 400px; - width: 100%; - margin-top: 24px; - padding: 20px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-md); -} - -/* Buttons */ -button, .btn { - background: var(--primary); - color: var(--primary-text); - padding: 9px 18px; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - text-decoration: none; - font-size: 14px; - font-weight: 700; - display: inline-flex; - align-items: center; - justify-content: center; - transition: all 0.15s ease; -} - -button:hover, .btn:hover { - background: var(--primary-hover); - transform: translateY(-1px); -} - -.btn-stats, .btn-nav { - background: var(--bg-card-hover); - color: var(--text-main); - border: 1px solid var(--border-hover); - font-weight: 600; -} - -.btn-stats:hover, .btn-nav:hover { - background: var(--border-hover); - color: var(--text-heading); -} - -.btn-logout, .btn-delete { - background: rgba(251, 73, 52, 0.15); - color: var(--danger); - border: 1px solid rgba(251, 73, 52, 0.3); - font-weight: 600; -} - -.btn-logout:hover, .btn-delete:hover { - background: var(--danger); - color: var(--bg-main); -} - -.btn-edit { - background: rgba(131, 165, 152, 0.15); - color: var(--accent-blue); - border: 1px solid rgba(131, 165, 152, 0.3); - text-decoration: none; - padding: 6px 12px; - border-radius: var(--radius-sm); - font-size: 13px; - font-weight: 600; - margin-right: 6px; - transition: all 0.15s ease; -} - -.btn-edit:hover { - background: var(--accent-blue); - color: var(--bg-main); -} - -.cancel { - background: var(--bg-card-hover); - color: var(--text-muted); - text-decoration: none; - padding: 9px 18px; - border-radius: var(--radius-sm); - display: inline-block; - margin-left: 10px; - font-size: 14px; - font-weight: 600; - border: 1px solid var(--border-hover); - transition: all 0.15s ease; -} - -.cancel:hover { - background: var(--border-hover); - color: var(--text-main); -} - -/* Django Form Styling */ -form p { - margin-bottom: 18px; -} - -form label { - display: block; - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - margin-bottom: 6px; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -form input[type="text"], -form input[type="password"], -form input[type="email"], -form input[type="number"], -form input[type="date"], -form select { - width: 100%; - box-sizing: border-box; - padding: 10px 14px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-size: 14px; - background-color: var(--bg-main); - color: var(--text-main); - transition: all 0.15s ease; -} - -form input:focus, -form select:focus { - outline: none; - border-color: var(--border-focus); - box-shadow: 0 0 0 3px rgba(250, 189, 47, 0.2); -} - -/* Django Form Password Requirements & Help Text */ -.helptext, form span.helptext { - display: block; - font-size: 12px; - color: var(--text-muted); - margin-top: 6px; - line-height: 1.5; -} - -.helptext ul { - margin: 6px 0 10px 0; - padding-left: 18px; - color: var(--text-muted); - font-size: 12px; -} - -.helptext li { - margin-bottom: 4px; -} - -/* Validation Errors */ -.errorlist { - color: var(--expense); - font-size: 13px; - list-style: none; - padding: 10px 14px; - margin: 6px 0 16px 0; - background: rgba(251, 73, 52, 0.1); - border: 1px solid rgba(251, 73, 52, 0.25); - border-radius: var(--radius-sm); - font-weight: 500; -} - -.errorlist li { - margin: 2px 0; -} diff --git a/core/templates/edit.html b/core/templates/edit.html deleted file mode 100644 index 4487f60..0000000 --- a/core/templates/edit.html +++ /dev/null @@ -1,21 +0,0 @@ -{% load static %} - - - - - - Edit Transaction - JAST - - - -

Editing: {{ transaction.title }}

-
-
- {% csrf_token %} - {{ form.as_p }} - - Cancel -
-
- - diff --git a/core/templates/home.html b/core/templates/home.html deleted file mode 100644 index 59afd59..0000000 --- a/core/templates/home.html +++ /dev/null @@ -1,71 +0,0 @@ -{% load static %} - - - - - - JAST - - - - - - -
-

Hello, {% if user.first_name %}{{ user.first_name }} {{ user.last_name }}{% else %}{{ user.username }}{% endif %}!

-

Total Balance: {{ total_balance|floatformat:2 }} kr

-
- -
-

Add Transaction

-
- {% csrf_token %} - {{ form.as_p }} - -
-
- - - - - - - - - - - - - {% for t in transactions %} - - - - - - - - {% empty %} - - {% endfor %} - -
DateTitleCategoryAmountActions
{{ t.date }}{{ t.title }}{{ t.category.name|default:"-" }} - {{ t.amount }} kr - - Edit -
- {% csrf_token %} - -
-
No transactions yet. Start spending!
- - - diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html deleted file mode 100644 index 44f0f69..0000000 --- a/core/templates/registration/login.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load static %} - - - - - - Login - JAST - - - -
-

Login to JAST

-
-
- {% csrf_token %} - {{ form.as_p }} - -
-
-

Don't have an account? Register here

-
- - diff --git a/core/templates/registration/register.html b/core/templates/registration/register.html deleted file mode 100644 index e450719..0000000 --- a/core/templates/registration/register.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load static %} - - - - - - Register - JAST - - - -
-

Register for JAST

-
-
- {% csrf_token %} - {{ form.as_p }} - -
-
-

Already have an account? Log in here

-
- - diff --git a/core/templates/stats.html b/core/templates/stats.html deleted file mode 100644 index d073a5c..0000000 --- a/core/templates/stats.html +++ /dev/null @@ -1,91 +0,0 @@ -{% load static %} - - - - - - JAST Statistics - - - - - - - - -

Spending Statistics

- - -
- Last 7 Days - Last 30 Days - Last 365 Days - All Time -
- - -
- -
- - - - diff --git a/core/urls.py b/core/urls.py index 2a14cfa..0d582a5 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,24 +1,11 @@ from django.contrib import admin from django.urls import path from django.contrib.auth import views as auth_views -from core import views from core.api import api # Import your Django Ninja API instance urlpatterns = [ # Admin Panel path('admin/', admin.site.urls), - - # Web App Views - path('', views.home, name='home'), - path('edit//', views.edit_transaction, name='edit_transaction'), - path('delete//', views.delete_transaction, name='delete_transaction'), - path('stats/', views.stats, name='stats'), - - # Auth Views - path('login/', auth_views.LoginView.as_view(template_name='registration/login.html'), name='login'), - path('logout/', auth_views.LogoutView.as_view(next_page='login'), name='logout'), - path('register/', views.register, name='register'), - # Django Ninja API path('api/', api.urls), ] diff --git a/core/views.py b/core/views.py deleted file mode 100644 index d1d7ceb..0000000 --- a/core/views.py +++ /dev/null @@ -1,93 +0,0 @@ -from django.shortcuts import render, redirect, get_object_or_404 -from django.db.models import Sum -from django.contrib.auth.decorators import login_required -from django.contrib.auth.forms import UserCreationForm -from django.contrib.auth import login -from .models import Transaction -from .forms import TransactionForm, CustomUserCreationForm -import json -from datetime import timedelta -from django.utils import timezone - -def register(request): - if request.method == 'POST': - form = CustomUserCreationForm(request.POST) - if form.is_valid(): - user = form.save() - login(request, user) - return redirect('home') - else: - form = CustomUserCreationForm() - - return render(request, 'registration/register.html', {'form': form}) - -@login_required -def home(request): - if request.method == 'POST': - form = TransactionForm(request.POST) - if form.is_valid(): - transaction = form.save(commit=False) - transaction.user = request.user - transaction.save() - return redirect('home') - else: - form = TransactionForm() - - transactions = Transaction.objects.filter(user=request.user) - total_balance = transactions.aggregate(Sum('amount'))['amount__sum'] or 0 - - context = { - 'form': form, - 'transactions': transactions, - 'total_balance': total_balance, - } - return render(request, 'home.html', context) - -@login_required -def edit_transaction(request, pk): - transaction = get_object_or_404(Transaction, pk=pk, user=request.user) - if request.method == 'POST': - form = TransactionForm(request.POST, instance=transaction) - if form.is_valid(): - form.save() - return redirect('home') - else: - form = TransactionForm(instance=transaction) - return render(request, 'edit.html', {'form': form, 'transaction': transaction}) - -@login_required -def delete_transaction(request, pk): - transaction = get_object_or_404(Transaction, pk=pk, user=request.user) - if request.method == 'POST': - transaction.delete() - return redirect('home') - -@login_required -def stats(request): - time_filter = request.GET.get('filter', 'all') - now = timezone.now().date() - - expenses = Transaction.objects.filter(user=request.user, amount__lt=0) - - if time_filter == 'week': - expenses = expenses.filter(date__gte=now - timedelta(days=7)) - elif time_filter == 'month': - expenses = expenses.filter(date__gte=now - timedelta(days=30)) - elif time_filter == 'year': - expenses = expenses.filter(date__gte=now - timedelta(days=365)) - - category_data = expenses.values('category__name').annotate(total=Sum('amount')) - - labels = [] - data = [] - for item in category_data: - cat_name = item['category__name'] or 'Uncategorized' - labels.append(cat_name) - data.append(abs(float(item['total']))) - - context = { - 'labels': json.dumps(labels), - 'data': json.dumps(data), - 'current_filter': time_filter, - } - return render(request, 'stats.html', context)