first version

This commit is contained in:
2026-07-14 20:10:17 +02:00
commit 4040b4fdd9
32 changed files with 2176 additions and 0 deletions
View File
+18
View File
@@ -0,0 +1,18 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from accounts.models import ApiToken, User
@admin.register(User)
class OsirisUserAdmin(UserAdmin):
pass
@admin.register(ApiToken)
class ApiTokenAdmin(admin.ModelAdmin):
"""Tokens are created by the Flutter app via /api/auth/login; this is for revoking."""
list_display = ["name", "user", "key", "created_at", "last_used_at"]
readonly_fields = ["key", "created_at", "last_used_at"]
list_filter = ["user"]
+50
View File
@@ -0,0 +1,50 @@
from django.contrib.auth import authenticate
from django.http import HttpRequest
from ninja import Router, Schema
from ninja.errors import HttpError
from accounts.auth import AUTH
from accounts.models import ApiToken
router = Router()
class LoginIn(Schema):
username: str
password: str
device_name: str = ""
class TokenOut(Schema):
token: str
username: str
class UserOut(Schema):
username: str
is_staff: bool
@router.post("/login", response=TokenOut, auth=None)
def login(request: HttpRequest, payload: LoginIn):
"""Exchange username/password for a bearer token. There is no registration."""
user = authenticate(request, username=payload.username, password=payload.password)
if user is None:
raise HttpError(401, "Invalid username or password.")
token = ApiToken.objects.create(user=user, name=payload.device_name)
return {"token": token.key, "username": user.username}
@router.post("/logout", auth=AUTH)
def logout(request: HttpRequest):
"""Revoke the token used for this request. Session logins are unaffected."""
token = request.auth
if isinstance(token, ApiToken):
token.delete()
return {"detail": "Logged out."}
@router.get("/me", response=UserOut, auth=AUTH)
def me(request: HttpRequest):
return request.user
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "accounts"
+29
View File
@@ -0,0 +1,29 @@
from typing import Optional
from django.http import HttpRequest
from ninja.security import HttpBearer, django_auth
from accounts.models import ApiToken
class TokenAuth(HttpBearer):
"""`Authorization: Bearer <key>` — how the Flutter app authenticates."""
def authenticate(self, request: HttpRequest, token: str) -> Optional[ApiToken]:
try:
api_token = ApiToken.objects.select_related("user").get(key=token)
except ApiToken.DoesNotExist:
return None
if not api_token.user.is_active:
return None
api_token.touch()
# Downstream views read request.user, same as with session auth.
request.user = api_token.user
return api_token
# Bearer first: a request carrying a token never reaches the cookie auth, which
# would reject it for a missing CSRF token.
AUTH = [TokenAuth(), django_auth]
+169
View File
@@ -0,0 +1,169 @@
# Generated by Django 6.0.7 on 2026-07-14 18:04
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="ApiToken",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(editable=False, max_length=64, unique=True)),
(
"name",
models.CharField(
blank=True,
help_text="Which device this token is for.",
max_length=100,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("last_used_at", models.DateTimeField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="api_tokens",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
]
View File
+42
View File
@@ -0,0 +1,42 @@
import secrets
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils import timezone
class User(AbstractUser):
"""Plain Django user. Accounts are created with `manage.py createsuperuser`."""
def __str__(self):
return self.username
class ApiToken(models.Model):
"""Bearer token for the Flutter app (the web admin uses session auth)."""
key = models.CharField(max_length=64, unique=True, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens"
)
name = models.CharField(
max_length=100, blank=True, help_text="Which device this token is for."
)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.user}{self.name or 'unnamed device'}"
def save(self, *args, **kwargs):
if not self.key:
self.key = secrets.token_urlsafe(32)
return super().save(*args, **kwargs)
def touch(self):
self.last_used_at = timezone.now()
self.save(update_fields=["last_used_at"])