First commit of claude's rework in django + vanillajs fronted
This commit is contained in:
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
7
config/pagination.py
Normal file
7
config/pagination.py
Normal 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
179
config/settings.py
Normal 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
54
config/urls.py
Normal 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
98
config/views.py
Normal 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
7
config/wsgi.py
Normal 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()
|
||||
Reference in New Issue
Block a user