First commit of claude's rework in django + vanillajs fronted

This commit is contained in:
Gérald Colangelo
2026-04-02 11:24:30 +02:00
parent 7710a876df
commit fde92f92db
163 changed files with 84852 additions and 15 deletions

0
config/__init__.py Normal file
View File

7
config/pagination.py Normal file
View File

@@ -0,0 +1,7 @@
from rest_framework.pagination import PageNumberPagination
class StandardPagination(PageNumberPagination):
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 1000

179
config/settings.py Normal file
View File

@@ -0,0 +1,179 @@
from datetime import timedelta
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env(
DEBUG=(bool, False),
)
environ.Env.read_env(BASE_DIR / '.env')
SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG')
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*'])
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
# Third-party
'rest_framework',
'rest_framework.authtoken',
'rest_framework_simplejwt.token_blacklist',
'django_filters',
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth.socialaccount.providers.google',
'allauth.socialaccount.providers.apple',
'dj_rest_auth',
'dj_rest_auth.registration',
# Local
'apps.users',
'apps.gears',
'apps.tools',
'apps.photos',
'apps.sessions',
'apps.calibers',
'apps.social',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
DATABASES = {
'default': env.db(
'DATABASE_URL',
default='postgresql://shooter:shooter_secret@db:5432/shooter_hub',
)
}
AUTH_USER_MODEL = 'users.User'
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
]
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
LANGUAGES = [
('en', 'English'),
('fr', 'Français'),
('de', 'Deutsch'),
('es', 'Español'),
]
LOCALE_PATHS = [BASE_DIR / 'locale']
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ── Django REST Framework ──────────────────────────────────────────────────────
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
'DEFAULT_PAGINATION_CLASS': 'config.pagination.StandardPagination',
'PAGE_SIZE': 20,
}
# ── SimpleJWT ─────────────────────────────────────────────────────────────────
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'AUTH_HEADER_TYPES': ('Bearer',),
}
# ── dj-rest-auth ──────────────────────────────────────────────────────────────
REST_AUTH = {
'USE_JWT': True,
'JWT_AUTH_HTTPONLY': False, # expose refresh token to mobile clients
}
# ── django-allauth ────────────────────────────────────────────────────────────
SITE_ID = 1
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = 'email'
ACCOUNT_EMAIL_VERIFICATION = 'none' # change to 'mandatory' in production
SOCIALACCOUNT_PROVIDERS = {
'google': {
'APP': {
'client_id': env('GOOGLE_CLIENT_ID', default=''),
'secret': env('GOOGLE_CLIENT_SECRET', default=''),
},
'SCOPE': ['profile', 'email'],
'AUTH_PARAMS': {'access_type': 'online'},
},
'apple': {
'APP': {
'client_id': env('APPLE_CLIENT_ID', default=''),
'secret': env('APPLE_CLIENT_SECRET', default=''),
},
},
}
# Development: print emails to console
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

54
config/urls.py Normal file
View File

@@ -0,0 +1,54 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from rest_framework_simplejwt.views import TokenRefreshView
from allauth.socialaccount.providers.apple.views import AppleOAuth2Adapter
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from dj_rest_auth.registration.views import SocialLoginView
from .views import public_feed
class GoogleLoginView(SocialLoginView):
adapter_class = GoogleOAuth2Adapter
client_class = OAuth2Client
class AppleLoginView(SocialLoginView):
adapter_class = AppleOAuth2Adapter
client_class = OAuth2Client
urlpatterns = [
path('admin/', admin.site.urls),
# Auth
path('api/auth/', include('dj_rest_auth.urls')),
path('api/auth/registration/', include('dj_rest_auth.registration.urls')),
path('api/auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
# Social / external IDP
# Mobile flow: POST {"access_token": "<idp_token>"} → returns JWT pair
path('api/auth/social/google/', GoogleLoginView.as_view(), name='google_login'),
path('api/auth/social/apple/', AppleLoginView.as_view(), name='apple_login'),
# Users app
path('api/users/', include('apps.users.urls')),
# Gears app
path('api/', include('apps.gears.urls')),
# Tools app
path('api/', include('apps.tools.urls')),
# Photos app
path('api/photos/', include('apps.photos.urls')),
# Sessions app
path('api/', include('apps.sessions.urls')),
# Calibers app
path('api/', include('apps.calibers.urls')),
# Social app (messages, blog, bugs, friends)
path('api/', include('apps.social.urls')),
# Public feed
path('api/feed/', public_feed, name='public-feed'),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

98
config/views.py Normal file
View File

@@ -0,0 +1,98 @@
"""
Public feed endpoint — returns the latest publicly shared items from each
content type. No authentication required.
"""
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from apps.photos.models import GroupPhoto
from apps.sessions.models import FreePracticeSession, PRSSession, SpeedShootingSession
from apps.tools.models import ChronographAnalysis
from apps.gears.models import ReloadRecipe
def _session_row(s, kind):
label = getattr(s, 'competition_name', None) or s.name or '(unnamed)'
return {
'id': s.id,
'type': kind,
'label': label,
'date': str(s.date) if s.date else None,
'location': s.location or None,
}
@api_view(['GET'])
@permission_classes([AllowAny])
def public_feed(request):
limit = min(int(request.query_params.get('limit', 8)), 20)
# ── Sessions (merge all types) ──────────────────────────────────────────
prs = list(PRSSession.objects.filter(is_public=True).order_by('-date')[:limit])
fp = list(FreePracticeSession.objects.filter(is_public=True).order_by('-date')[:limit])
ss = list(SpeedShootingSession.objects.filter(is_public=True).order_by('-date')[:limit])
sessions_raw = (
[_session_row(s, 'PRS') for s in prs] +
[_session_row(s, 'Practice') for s in fp] +
[_session_row(s, 'Speed') for s in ss]
)
sessions = sorted(sessions_raw, key=lambda x: x['date'] or '', reverse=True)[:limit]
# ── Analyses ────────────────────────────────────────────────────────────
analyses_qs = (
ChronographAnalysis.objects
.filter(is_public=True)
.order_by('-date')[:limit]
)
analyses = [
{
'id': a.id,
'name': a.name,
'date': str(a.date) if a.date else None,
}
for a in analyses_qs
]
# ── Photos ──────────────────────────────────────────────────────────────
photos_qs = (
GroupPhoto.objects
.filter(is_public=True)
.select_related('photo', 'analysis')
.order_by('-photo__uploaded_at')[:limit]
)
photos = []
for gp in photos_qs:
an = getattr(gp, 'analysis', None)
photos.append({
'id': gp.id,
'photo_id': gp.photo_id,
'caption': gp.caption or None,
'group_size_mm': str(an.group_size_mm) if an and an.group_size_mm is not None else None,
'group_size_moa': str(an.group_size_moa) if an and an.group_size_moa is not None else None,
})
# ── Reload recipes ──────────────────────────────────────────────────────
recipes_qs = (
ReloadRecipe.objects
.filter(is_public=True)
.select_related('caliber', 'bullet', 'primer', 'brass')
.order_by('-created_at')[:limit]
)
recipes = []
for r in recipes_qs:
recipes.append({
'id': r.id,
'name': r.name,
'caliber': r.caliber.name if r.caliber_id else None,
'bullet': str(r.bullet) if r.bullet_id else None,
'date': str(r.created_at.date()),
})
return Response({
'sessions': sessions,
'analyses': analyses,
'photos': photos,
'recipes': recipes,
})

7
config/wsgi.py Normal file
View File

@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()