From a8ab5f6ce14371677f36253dc327dcdf079129e2 Mon Sep 17 00:00:00 2001 From: Boris Date: Sun, 29 Mar 2026 22:18:32 +0300 Subject: [PATCH] Add backlogger app at /backlogger/ with login protection Django app with Item model (games/books/films/other categories), CRUD views, and login-required access. Login page at /accounts/login/ uses custom dark-themed template consistent with the site design. Co-Authored-By: Claude Sonnet 4.6 --- backlogger/__init__.py | 0 backlogger/admin.py | 9 + backlogger/apps.py | 6 + backlogger/forms.py | 46 ++++ backlogger/migrations/0001_initial.py | 32 +++ backlogger/migrations/__init__.py | 0 backlogger/models.py | 39 +++ .../templates/backlogger/item_form.html | 260 ++++++++++++++++++ backlogger/templates/backlogger/list.html | 237 ++++++++++++++++ backlogger/templates/backlogger/login.html | 114 ++++++++ backlogger/urls.py | 10 + backlogger/views.py | 64 +++++ kboris/settings.py | 4 + kboris/urls.py | 4 + 14 files changed, 825 insertions(+) create mode 100644 backlogger/__init__.py create mode 100644 backlogger/admin.py create mode 100644 backlogger/apps.py create mode 100644 backlogger/forms.py create mode 100644 backlogger/migrations/0001_initial.py create mode 100644 backlogger/migrations/__init__.py create mode 100644 backlogger/models.py create mode 100644 backlogger/templates/backlogger/item_form.html create mode 100644 backlogger/templates/backlogger/list.html create mode 100644 backlogger/templates/backlogger/login.html create mode 100644 backlogger/urls.py create mode 100644 backlogger/views.py diff --git a/backlogger/__init__.py b/backlogger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backlogger/admin.py b/backlogger/admin.py new file mode 100644 index 0000000..c7f6669 --- /dev/null +++ b/backlogger/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import Item + + +@admin.register(Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ['name', 'category', 'progress_percent', 'favorite', 'created_at'] + list_filter = ['category', 'favorite'] + search_fields = ['name'] diff --git a/backlogger/apps.py b/backlogger/apps.py new file mode 100644 index 0000000..71b6767 --- /dev/null +++ b/backlogger/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BackloggerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'backlogger' diff --git a/backlogger/forms.py b/backlogger/forms.py new file mode 100644 index 0000000..674c78e --- /dev/null +++ b/backlogger/forms.py @@ -0,0 +1,46 @@ +from django import forms +from .models import Item + + +class ItemForm(forms.ModelForm): + progress_percent = forms.FloatField( + min_value=0, + max_value=100, + initial=0, + widget=forms.NumberInput(attrs={ + 'type': 'range', + 'min': '0', + 'max': '100', + 'step': '1', + }), + ) + + class Meta: + model = Item + fields = [ + 'category', 'name', 'progress_percent', 'favorite', + 'hours_played', 'total_hours', + 'pages_read', 'total_pages', + 'watched', 'duration_minutes', + ] + widgets = { + 'hours_played': forms.NumberInput(attrs={'step': '0.5', 'min': '0'}), + 'total_hours': forms.NumberInput(attrs={'step': '0.5', 'min': '0'}), + 'pages_read': forms.NumberInput(attrs={'min': '0'}), + 'total_pages': forms.NumberInput(attrs={'min': '0'}), + 'duration_minutes': forms.NumberInput(attrs={'min': '0'}), + } + + def clean(self): + cleaned_data = super().clean() + category = cleaned_data.get('category') + if category != Item.GAMES: + cleaned_data['hours_played'] = None + cleaned_data['total_hours'] = None + if category != Item.BOOKS: + cleaned_data['pages_read'] = None + cleaned_data['total_pages'] = None + if category != Item.FILMS: + cleaned_data['watched'] = None + cleaned_data['duration_minutes'] = None + return cleaned_data diff --git a/backlogger/migrations/0001_initial.py b/backlogger/migrations/0001_initial.py new file mode 100644 index 0000000..1530520 --- /dev/null +++ b/backlogger/migrations/0001_initial.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(choices=[('games', 'Games'), ('books', 'Books'), ('films', 'Films'), ('other', 'Other')], max_length=10)), + ('name', models.CharField(max_length=200)), + ('progress_percent', models.FloatField(default=0.0)), + ('favorite', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('hours_played', models.FloatField(blank=True, null=True)), + ('total_hours', models.FloatField(blank=True, null=True)), + ('pages_read', models.IntegerField(blank=True, null=True)), + ('total_pages', models.IntegerField(blank=True, null=True)), + ('watched', models.BooleanField(blank=True, null=True)), + ('duration_minutes', models.IntegerField(blank=True, null=True)), + ], + options={ + 'ordering': ['-favorite', 'name'], + }, + ), + ] diff --git a/backlogger/migrations/__init__.py b/backlogger/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backlogger/models.py b/backlogger/models.py new file mode 100644 index 0000000..19bc14f --- /dev/null +++ b/backlogger/models.py @@ -0,0 +1,39 @@ +from django.db import models + + +class Item(models.Model): + GAMES = 'games' + BOOKS = 'books' + FILMS = 'films' + OTHER = 'other' + CATEGORY_CHOICES = [ + (GAMES, 'Games'), + (BOOKS, 'Books'), + (FILMS, 'Films'), + (OTHER, 'Other'), + ] + + category = models.CharField(max_length=10, choices=CATEGORY_CHOICES) + name = models.CharField(max_length=200) + progress_percent = models.FloatField(default=0.0) + favorite = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Games + hours_played = models.FloatField(null=True, blank=True) + total_hours = models.FloatField(null=True, blank=True) + + # Books + pages_read = models.IntegerField(null=True, blank=True) + total_pages = models.IntegerField(null=True, blank=True) + + # Films + watched = models.BooleanField(null=True, blank=True) + duration_minutes = models.IntegerField(null=True, blank=True) + + class Meta: + ordering = ['-favorite', 'name'] + + def __str__(self): + return f"{self.get_category_display()}: {self.name}" diff --git a/backlogger/templates/backlogger/item_form.html b/backlogger/templates/backlogger/item_form.html new file mode 100644 index 0000000..5627b0f --- /dev/null +++ b/backlogger/templates/backlogger/item_form.html @@ -0,0 +1,260 @@ + + + + + + {{ action }} Item — Backlogger + + + + + + +
+
+

{{ action }} item

+ +
+ {% csrf_token %} + +
+ + {{ form.category }} + {{ form.category.errors }} +
+ +
+ + {{ form.name }} + {{ form.name.errors }} +
+ +
+
+ + {{ form.progress_percent.value|default:0|floatformat:0 }}% +
+ {{ form.progress_percent }} + {{ form.progress_percent.errors }} +
+ +
+
+ {{ form.favorite }} + +
+
+ + + + + + + + + + +
+ Cancel + +
+
+
+
+ + + + + diff --git a/backlogger/templates/backlogger/list.html b/backlogger/templates/backlogger/list.html new file mode 100644 index 0000000..71cb05a --- /dev/null +++ b/backlogger/templates/backlogger/list.html @@ -0,0 +1,237 @@ + + + + + + Backlogger — k-boris.tech + + + + + + +
+
+

Backlogger {{ items|length }}

+ + Add item +
+ +
+
+ All + {% for val, label in categories %} + {{ label }} + {% endfor %} +
+
+ +
+
+ + {% if items %} +
+ {% for item in items %} +
+
+ {{ item.get_category_display }} + {% if item.favorite %}{% endif %} +
+ +
{{ item.name }}
+ +
+
+
+
{{ item.progress_percent|floatformat:0 }}%
+ +
+ {% if item.category == 'games' %} + {% if item.hours_played is not None %} + {{ item.hours_played|floatformat:1 }}h played{% if item.total_hours %} / {{ item.total_hours|floatformat:0 }}h total{% endif %} + {% endif %} + {% elif item.category == 'books' %} + {% if item.pages_read is not None %} + {{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %} + {% endif %} + {% elif item.category == 'films' %} + {% if item.watched %}✓ Watched{% else %}○ Not watched{% endif %}{% if item.duration_minutes %} · {{ item.duration_minutes }} min{% endif %} + {% endif %} +
+ +
+ Edit +
+ {% csrf_token %} + +
+
+
+ {% endfor %} +
+ {% else %} +
+ No items here yet. Add your first one. +
+ {% endif %} +
+ + + diff --git a/backlogger/templates/backlogger/login.html b/backlogger/templates/backlogger/login.html new file mode 100644 index 0000000..fe4df84 --- /dev/null +++ b/backlogger/templates/backlogger/login.html @@ -0,0 +1,114 @@ + + + + + + Log in — k-boris.tech + + + +
+ k-boris.tech +

Backlogger

+ + {% if form.non_field_errors %} +
Invalid username or password.
+ {% endif %} + +
+ {% csrf_token %} + + +
+ + {{ form.username }} + {{ form.username.errors }} +
+
+ + {{ form.password }} + {{ form.password.errors }} +
+ + +
+
+ + diff --git a/backlogger/urls.py b/backlogger/urls.py new file mode 100644 index 0000000..c4978d7 --- /dev/null +++ b/backlogger/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +app_name = 'backlogger' +urlpatterns = [ + path('', views.item_list, name='list'), + path('add/', views.item_add, name='add'), + path('/edit/', views.item_edit, name='edit'), + path('/delete/', views.item_delete, name='delete'), +] diff --git a/backlogger/views.py b/backlogger/views.py new file mode 100644 index 0000000..0e2bbf6 --- /dev/null +++ b/backlogger/views.py @@ -0,0 +1,64 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, get_object_or_404, redirect +from .models import Item +from .forms import ItemForm + + +SORT_MAP = { + 'fav': ['-favorite', 'name'], + 'az': ['name'], + 'za': ['-name'], + 'newest': ['-created_at'], + 'oldest': ['created_at'], + 'progress': ['-progress_percent'], +} + + +@login_required +def item_list(request): + category = request.GET.get('category', '') + sort = request.GET.get('sort', 'fav') + + items = Item.objects.all() + if category: + items = items.filter(category=category) + items = items.order_by(*SORT_MAP.get(sort, SORT_MAP['fav'])) + + return render(request, 'backlogger/list.html', { + 'items': items, + 'category': category, + 'sort': sort, + 'categories': Item.CATEGORY_CHOICES, + }) + + +@login_required +def item_add(request): + if request.method == 'POST': + form = ItemForm(request.POST) + if form.is_valid(): + form.save() + return redirect('backlogger:list') + else: + form = ItemForm() + return render(request, 'backlogger/item_form.html', {'form': form, 'action': 'Add'}) + + +@login_required +def item_edit(request, pk): + item = get_object_or_404(Item, pk=pk) + if request.method == 'POST': + form = ItemForm(request.POST, instance=item) + if form.is_valid(): + form.save() + return redirect('backlogger:list') + else: + form = ItemForm(instance=item) + return render(request, 'backlogger/item_form.html', {'form': form, 'action': 'Edit', 'item': item}) + + +@login_required +def item_delete(request, pk): + if request.method == 'POST': + get_object_or_404(Item, pk=pk).delete() + return redirect('backlogger:list') diff --git a/kboris/settings.py b/kboris/settings.py index 0adb111..12d6902 100644 --- a/kboris/settings.py +++ b/kboris/settings.py @@ -29,8 +29,12 @@ INSTALLED_APPS = [ 'whitenoise.runserver_nostatic', 'django.contrib.staticfiles', 'core', + 'backlogger', ] +LOGIN_REDIRECT_URL = '/backlogger/' +LOGOUT_REDIRECT_URL = '/' + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', diff --git a/kboris/urls.py b/kboris/urls.py index ac12108..65d21f4 100644 --- a/kboris/urls.py +++ b/kboris/urls.py @@ -1,7 +1,11 @@ from django.contrib import admin +from django.contrib.auth import views as auth_views from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), + path('accounts/login/', auth_views.LoginView.as_view(template_name='backlogger/login.html'), name='login'), + path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'), + path('backlogger/', include('backlogger.urls')), path('', include('core.urls')), ]