diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6acb670 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# k-boris website — Claude context + +Django project serving **k-boris.tech** and **killmybacklog.com**. + +## Project layout + +``` +website/ + backlogger/ # Main app: game/book/movie backlog tracker + core/ # Landing page and shared views + dailystone/ # Daily mineral fact feature + kboris/ # Django project settings & URLs + nginx/ # Nginx site configs (for reference; deployed on VPS) + Dockerfile + entrypoint.sh # Runs migrate + loaddata, then gunicorn on :8080 + requirements.txt + .woodpecker.yml +``` + +- Database: SQLite at `/data/db.sqlite3` (volume-mounted on VPS, not committed) +- Static files: collected to `staticfiles/`, served by WhiteNoise +- Auth env vars: `DJANGO_SECRET_KEY`, `STEAM_API_KEY` + +## CI/CD — Woodpecker + +Woodpecker CI runs at **https://ci.k-boris.tech** (Gitea OAuth, admin: boris). + +Pipeline defined in `.woodpecker.yml`: +1. **test** step: runs `python manage.py test backlogger` (all branches) +2. **build-and-deploy** (main only): builds `k-boris-website` image, restarts `django` container +3. **build-and-deploy-dev** (dev only): builds `k-boris-website-dev` image, restarts `django-dev` container + +Push to `main` → production. Push to `dev` → staging (DEBUG=true, port 8081). + +The agent shares the host Docker socket, so it builds directly on the VPS. + +## VPS access & Docker logs + +```bash +ssh boris@46.202.143.107 # key: ~/.ssh/id_ed25519 + +# Production logs +ssh boris@46.202.143.107 "docker logs django --tail=50" +ssh boris@46.202.143.107 "docker logs django -f" + +# Dev/staging logs +ssh boris@46.202.143.107 "docker logs django-dev --tail=50" + +# Woodpecker / Gitea logs +ssh boris@46.202.143.107 "docker logs woodpecker-agent --tail=50" +ssh boris@46.202.143.107 "docker logs gitea --tail=30" + +# All services +ssh boris@46.202.143.107 "docker ps" +ssh boris@46.202.143.107 "cd /opt/services && docker compose ps" +``` + +Compose file lives at `/opt/services/docker-compose.yml` on the VPS. + +## Running locally with Docker (preferred) + +Avoid setting up a Python venv locally — use Docker instead for quick checks: + +```bash +cd website + +# Build +docker build --build-arg DJANGO_SECRET_KEY=local-dev-key -t k-boris-local . + +# Run (no persistent data) +docker run --rm -p 8080:8080 \ + -e DJANGO_SECRET_KEY=local-dev-key \ + -e STEAM_API_KEY=your_key_here \ + -e DEBUG=true \ + k-boris-local + +# Run with a persistent local DB volume +docker run --rm -p 8080:8080 \ + -e DJANGO_SECRET_KEY=local-dev-key \ + -e DEBUG=true \ + -v "$(pwd)/.local-data:/data" \ + k-boris-local +``` + +Then open http://localhost:8080. + +## Running tests locally + +Tests don't need a running server — use the Docker image or a venv: + +```bash +# Via Docker (no setup needed) +docker run --rm \ + -e DJANGO_SECRET_KEY=test-key \ + k-boris-local \ + python manage.py test backlogger --verbosity=2 + +# Or directly if you have a venv active +python manage.py test backlogger +``` + +Tests live in `backlogger/tests.py` only. The CI only tests the `backlogger` app. + +## Key URLs + +| Environment | URL | Container | Port | +|---|---|---|---| +| Production | https://k-boris.tech / https://killmybacklog.com | `django` | 8080 | +| Staging/dev | https://debug.killmybacklog.com | `django-dev` | 8081 | +| Gitea | https://git.k-boris.tech | `gitea` | 3000 | +| Woodpecker | https://ci.k-boris.tech | `woodpecker-server` | 8000 | + +## Workflow + +1. Develop locally, test with Docker +2. Push to `dev` → CI runs tests + deploys to staging automatically +3. Check staging at https://debug.killmybacklog.com +4. Merge/push to `main` → CI deploys to production +5. Tail production logs via SSH if something looks wrong diff --git a/backlogger/apps.py b/backlogger/apps.py index 71b6767..de61207 100644 --- a/backlogger/apps.py +++ b/backlogger/apps.py @@ -4,3 +4,14 @@ from django.apps import AppConfig class BackloggerConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'backlogger' + + def ready(self): + from django.contrib.auth.signals import user_logged_in + + def set_theme_on_login(sender, request, user, **kwargs): + try: + request.session['theme'] = user.profile.theme + except Exception: + pass + + user_logged_in.connect(set_theme_on_login) diff --git a/backlogger/forms.py b/backlogger/forms.py index 351ac85..8222f5a 100644 --- a/backlogger/forms.py +++ b/backlogger/forms.py @@ -1,7 +1,7 @@ from django import forms from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.models import User -from .models import Item +from .models import Item, UserProfile class SignupForm(UserCreationForm): @@ -54,3 +54,16 @@ class ItemForm(forms.ModelForm): cleaned_data['watched'] = None cleaned_data['duration_minutes'] = None return cleaned_data + + +class ProfileForm(forms.ModelForm): + class Meta: + model = UserProfile + fields = ['display_name', 'theme'] + widgets = { + 'display_name': forms.TextInput(attrs={'placeholder': 'Your full name (optional)'}), + } + labels = { + 'display_name': 'Display name', + 'theme': 'Theme', + } diff --git a/backlogger/hltb.py b/backlogger/hltb.py new file mode 100644 index 0000000..181eb03 --- /dev/null +++ b/backlogger/hltb.py @@ -0,0 +1,48 @@ +from howlongtobeatpy import HowLongToBeat + + +def _h(val): + """Return float hours if valid, else None.""" + try: + v = float(val) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def fetch(game_name): + """ + Search HowLongToBeat for game_name. + Returns dict with keys 'main', 'extra', 'complete' (each float or None), + or None if nothing found / on any error. + """ + try: + results = HowLongToBeat().search(game_name) + except Exception: + return None + + if not results: + return None + + best = max(results, key=lambda r: r.similarity) + if best.similarity < 0.4: + return None + + return { + 'main': _h(best.main_story), + 'extra': _h(best.main_extra), + 'complete': _h(best.completionist), + } + + +def apply_to_item(item): + """Fetch HLTB data and save it onto item. Silently does nothing on failure.""" + if item.category != 'games' or not item.name: + return + data = fetch(item.name) + if data is None: + return + item.hltb_main = data['main'] + item.hltb_extra = data['extra'] + item.hltb_complete = data['complete'] + item.save(update_fields=['hltb_main', 'hltb_extra', 'hltb_complete']) diff --git a/backlogger/migrations/0004_item_hltb_fields.py b/backlogger/migrations/0004_item_hltb_fields.py new file mode 100644 index 0000000..52576da --- /dev/null +++ b/backlogger/migrations/0004_item_hltb_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0003_assign_existing_items_to_first_user'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='hltb_main', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='item', + name='hltb_extra', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='item', + name='hltb_complete', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/backlogger/migrations/0005_item_status.py b/backlogger/migrations/0005_item_status.py new file mode 100644 index 0000000..f04592d --- /dev/null +++ b/backlogger/migrations/0005_item_status.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0004_item_hltb_fields'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='status', + field=models.CharField( + choices=[('active', 'Active'), ('completed', 'Completed'), ('abandoned', 'Abandoned')], + default='active', + max_length=10, + ), + ), + ] diff --git a/backlogger/migrations/0006_item_category_unending.py b/backlogger/migrations/0006_item_category_unending.py new file mode 100644 index 0000000..1849bad --- /dev/null +++ b/backlogger/migrations/0006_item_category_unending.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0005_item_status'), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='category', + field=models.CharField( + choices=[ + ('games', 'Games'), + ('books', 'Books'), + ('films', 'Films'), + ('unending', 'Unending'), + ('other', 'Other'), + ], + max_length=10, + ), + ), + ] diff --git a/backlogger/migrations/0006_item_status_unending.py b/backlogger/migrations/0006_item_status_unending.py new file mode 100644 index 0000000..85485ba --- /dev/null +++ b/backlogger/migrations/0006_item_status_unending.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0005_item_status'), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='status', + field=models.CharField( + choices=[ + ('active', 'Active'), + ('completed', 'Completed'), + ('abandoned', 'Abandoned'), + ('unending', 'Unending'), + ], + default='active', + max_length=10, + ), + ), + ] diff --git a/backlogger/migrations/0007_merge.py b/backlogger/migrations/0007_merge.py new file mode 100644 index 0000000..bd370ac --- /dev/null +++ b/backlogger/migrations/0007_merge.py @@ -0,0 +1,12 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0006_item_category_unending'), + ('backlogger', '0006_item_status_unending'), + ] + + operations = [ + ] diff --git a/backlogger/migrations/0008_fix_category_choices.py b/backlogger/migrations/0008_fix_category_choices.py new file mode 100644 index 0000000..c042dbc --- /dev/null +++ b/backlogger/migrations/0008_fix_category_choices.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0007_merge'), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='category', + field=models.CharField( + choices=[ + ('games', 'Games'), + ('books', 'Books'), + ('films', 'Films'), + ('other', 'Other'), + ], + max_length=10, + ), + ), + ] diff --git a/backlogger/migrations/0009_userprofile.py b/backlogger/migrations/0009_userprofile.py new file mode 100644 index 0000000..584a166 --- /dev/null +++ b/backlogger/migrations/0009_userprofile.py @@ -0,0 +1,23 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0008_fix_category_choices'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('display_name', models.CharField(blank=True, max_length=100)), + ('theme', models.CharField(choices=[('dark', 'Dark'), ('light', 'Light')], default='dark', max_length=10)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backlogger/models.py b/backlogger/models.py index eef1e25..2391dc3 100644 --- a/backlogger/models.py +++ b/backlogger/models.py @@ -2,6 +2,19 @@ from django.contrib.auth.models import User from django.db import models +class UserProfile(models.Model): + DARK = 'dark' + LIGHT = 'light' + THEME_CHOICES = [(DARK, 'Dark'), (LIGHT, 'Light')] + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + display_name = models.CharField(max_length=100, blank=True) + theme = models.CharField(max_length=10, choices=THEME_CHOICES, default=DARK) + + def __str__(self): + return f"Profile({self.user.username})" + + class Item(models.Model): GAMES = 'games' BOOKS = 'books' @@ -14,9 +27,21 @@ class Item(models.Model): (OTHER, 'Other'), ] + ACTIVE = 'active' + COMPLETED = 'completed' + ABANDONED = 'abandoned' + UNENDING = 'unending' + STATUS_CHOICES = [ + (ACTIVE, 'Active'), + (COMPLETED, 'Completed'), + (ABANDONED, 'Abandoned'), + (UNENDING, 'Unending'), + ] + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='items', null=True) category = models.CharField(max_length=10, choices=CATEGORY_CHOICES) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=ACTIVE) name = models.CharField(max_length=200) progress_percent = models.FloatField(default=0.0) favorite = models.BooleanField(default=False) @@ -35,6 +60,11 @@ class Item(models.Model): watched = models.BooleanField(null=True, blank=True) duration_minutes = models.IntegerField(null=True, blank=True) + # HowLongToBeat estimates (games only) + hltb_main = models.FloatField(null=True, blank=True) + hltb_extra = models.FloatField(null=True, blank=True) + hltb_complete = models.FloatField(null=True, blank=True) + class Meta: ordering = ['-favorite', 'name'] diff --git a/backlogger/steam.py b/backlogger/steam.py new file mode 100644 index 0000000..898ad47 --- /dev/null +++ b/backlogger/steam.py @@ -0,0 +1,50 @@ +import re +import requests +from urllib.parse import urlencode + +STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login' +STEAM_API_BASE = 'https://api.steampowered.com' + + +def build_auth_url(return_to, realm): + params = { + 'openid.ns': 'http://specs.openid.net/auth/2.0', + 'openid.mode': 'checkid_setup', + 'openid.return_to': return_to, + 'openid.realm': realm, + 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select', + 'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select', + } + return f"{STEAM_OPENID_URL}?{urlencode(params)}" + + +def verify_and_get_steam_id(params): + """Verify OpenID assertion with Steam. Returns steam64 id string or None.""" + verify_params = {k: v for k, v in params.items()} + verify_params['openid.mode'] = 'check_authentication' + try: + resp = requests.post(STEAM_OPENID_URL, data=verify_params, timeout=10) + resp.raise_for_status() + except requests.RequestException: + return None + if 'is_valid:true' not in resp.text: + return None + claimed_id = params.get('openid.claimed_id', '') + match = re.search(r'/openid/id/(\d+)$', claimed_id) + return match.group(1) if match else None + + +def get_owned_games(api_key, steam_id): + """Return list of games sorted by playtime desc. Raises on API error.""" + url = f"{STEAM_API_BASE}/IPlayerService/GetOwnedGames/v1/" + params = { + 'key': api_key, + 'steamid': steam_id, + 'include_appinfo': 'true', + 'include_played_free_games': 'true', + 'format': 'json', + } + resp = requests.get(url, params=params, timeout=15) + resp.raise_for_status() + games = resp.json().get('response', {}).get('games', []) + return sorted(games, key=lambda g: g.get('playtime_forever', 0), reverse=True) diff --git a/backlogger/templates/backlogger/item_form.html b/backlogger/templates/backlogger/item_form.html index 5627b0f..fce87bb 100644 --- a/backlogger/templates/backlogger/item_form.html +++ b/backlogger/templates/backlogger/item_form.html @@ -158,7 +158,7 @@ {{ form.name.errors }} -