rinogeek commited on
Commit
385a349
·
0 Parent(s):

Initial commit of backend code to Hugging Face Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +10 -0
  2. .gitignore +69 -0
  3. Akompta/__init__.py +0 -0
  4. Akompta/asgi.py +16 -0
  5. Akompta/settings.py +309 -0
  6. Akompta/urls.py +22 -0
  7. Akompta/wsgi.py +16 -0
  8. Dockerfile +38 -0
  9. Documentation/API_EXEMPLES.MD +469 -0
  10. README.md +38 -0
  11. api/__init__.py +0 -0
  12. api/admin.py +248 -0
  13. api/apps.py +6 -0
  14. api/assemblyai_service.py +105 -0
  15. api/exceptions.py +47 -0
  16. api/gemini_service.py +237 -0
  17. api/groq_service.py +136 -0
  18. api/management/__init__.py +0 -0
  19. api/management/commands/__init__.py +0 -0
  20. api/management/commands/seed_data.py +213 -0
  21. api/migrations/0001_initial.py +141 -0
  22. api/migrations/0002_alter_user_managers.py +18 -0
  23. api/migrations/0003_notification_supportticket.py +49 -0
  24. api/migrations/0004_aiinsight.py +30 -0
  25. api/migrations/0005_user_initial_balance.py +19 -0
  26. api/migrations/__init__.py +0 -0
  27. api/models.py +298 -0
  28. api/serializers.py +249 -0
  29. api/tests.py +367 -0
  30. api/tests_new.py +115 -0
  31. api/urls.py +43 -0
  32. api/views.py +839 -0
  33. manage.py +22 -0
  34. requirements.txt +39 -0
  35. start.sh +18 -0
  36. static/admin/css/autocomplete.css +279 -0
  37. static/admin/css/base.css +1180 -0
  38. static/admin/css/changelists.css +343 -0
  39. static/admin/css/dark_mode.css +130 -0
  40. static/admin/css/dashboard.css +29 -0
  41. static/admin/css/forms.css +498 -0
  42. static/admin/css/login.css +61 -0
  43. static/admin/css/nav_sidebar.css +150 -0
  44. static/admin/css/responsive.css +904 -0
  45. static/admin/css/responsive_rtl.css +89 -0
  46. static/admin/css/rtl.css +293 -0
  47. static/admin/css/unusable_password_field.css +19 -0
  48. static/admin/css/vendor/select2/LICENSE-SELECT2.md +21 -0
  49. static/admin/css/vendor/select2/select2.css +481 -0
  50. static/admin/css/vendor/select2/select2.min.css +1 -0
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ *.pyc
3
+ __pycache__/
4
+ db.sqlite3
5
+ logs/
6
+ media/
7
+ static/
8
+ .env
9
+ .git
10
+ .gitignore
.gitignore ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ build/
7
+ develop-eggs/
8
+ dist/
9
+ downloads/
10
+ eggs/
11
+ .eggs/
12
+ lib/
13
+ lib64/
14
+ parts/
15
+ sdist/
16
+ var/
17
+ wheels/
18
+ *.egg-info/
19
+ .installed.cfg
20
+ *.egg
21
+
22
+ # Virtual Environment
23
+ venv/
24
+ env/
25
+ ENV/
26
+ env.bak/
27
+ venv.bak/
28
+
29
+ # Django
30
+ *.log
31
+ local_settings.py
32
+ db.sqlite3
33
+ db.sqlite3-journal
34
+ staticfiles/
35
+ media/
36
+
37
+ # Environment variables
38
+ .env
39
+ .env.local
40
+ .env.example
41
+ .env.*.local
42
+
43
+ # IDEs
44
+ .vscode/
45
+ .idea/
46
+ *.swp
47
+ *.swo
48
+ *~
49
+ .DS_Store
50
+
51
+ # Testing
52
+ .coverage
53
+ .pytest_cache/
54
+ htmlcov/
55
+ .tox/
56
+ .hypothesis/
57
+
58
+ # Logs
59
+ logs/
60
+ *.log
61
+
62
+ # Backup files
63
+ *.bak
64
+ *.tmp
65
+ *.temp
66
+
67
+ # OS
68
+ Thumbs.db
69
+ .DS_Store
Akompta/__init__.py ADDED
File without changes
Akompta/asgi.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASGI config for Akompta project.
3
+
4
+ It exposes the ASGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.asgi import get_asgi_application
13
+
14
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Akompta.settings')
15
+
16
+ application = get_asgi_application()
Akompta/settings.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Django settings for Akompta project.
3
+
4
+ Generated by 'django-admin startproject' using Django 5.2.8.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/topics/settings/
8
+
9
+ For the full list of settings and their values, see
10
+ https://docs.djangoproject.com/en/5.2/ref/settings/
11
+ """
12
+
13
+ import os
14
+ from pathlib import Path
15
+ from datetime import timedelta
16
+ from decouple import config, Csv
17
+
18
+ # Build paths inside the project like this: BASE_DIR / 'subdir'.
19
+ BASE_DIR = Path(__file__).resolve().parent.parent
20
+
21
+
22
+ # Quick-start development settings - unsuitable for production
23
+ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
24
+
25
+ # SECURITY WARNING: keep the secret key used in production secret!
26
+ SECRET_KEY = config('SECRET_KEY', default='django-insecure-3m1!a3u-z=5k8x9y#-954&3ree&mr&$o97fuy8ds*8dox!(rvx')
27
+
28
+ # SECURITY WARNING: don't run with debug turned on in production!
29
+ DEBUG = config('DEBUG', default=True, cast=bool)
30
+
31
+ ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv())
32
+
33
+ # CSRF Trusted Origins for Hugging Face and Frontend
34
+ CSRF_TRUSTED_ORIGINS = config(
35
+ 'CSRF_TRUSTED_ORIGINS',
36
+ default='https://*.hf.space,https://*.huggingface.co,https://akompta-ai-flame.vercel.app,https://cosmolabhub-akomptabackend.hf.space',
37
+ cast=Csv()
38
+ )
39
+
40
+
41
+ # Application definition
42
+
43
+ INSTALLED_APPS = [
44
+ 'django.contrib.admin',
45
+ 'django.contrib.auth',
46
+ 'django.contrib.contenttypes',
47
+ 'django.contrib.sessions',
48
+ 'django.contrib.messages',
49
+ 'django.contrib.staticfiles',
50
+
51
+ # Third party
52
+ 'rest_framework',
53
+ 'rest_framework_simplejwt',
54
+ 'corsheaders',
55
+ 'django_filters',
56
+
57
+ # Local apps
58
+ 'api', # Votre app principale
59
+ ]
60
+
61
+ MIDDLEWARE = [
62
+ 'django.middleware.security.SecurityMiddleware',
63
+ 'whitenoise.middleware.WhiteNoiseMiddleware',
64
+ 'corsheaders.middleware.CorsMiddleware',
65
+ 'django.contrib.sessions.middleware.SessionMiddleware',
66
+ 'django.middleware.common.CommonMiddleware',
67
+ 'django.middleware.csrf.CsrfViewMiddleware',
68
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
69
+ 'django.contrib.messages.middleware.MessageMiddleware',
70
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
71
+ ]
72
+
73
+ ROOT_URLCONF = 'Akompta.urls'
74
+
75
+ TEMPLATES = [
76
+ {
77
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
78
+ 'DIRS': [],
79
+ 'APP_DIRS': True,
80
+ 'OPTIONS': {
81
+ 'context_processors': [
82
+ 'django.template.context_processors.request',
83
+ 'django.contrib.auth.context_processors.auth',
84
+ 'django.contrib.messages.context_processors.messages',
85
+ ],
86
+ },
87
+ },
88
+ ]
89
+
90
+ WSGI_APPLICATION = 'Akompta.wsgi.application'
91
+
92
+
93
+ # Database
94
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
95
+
96
+ DATABASES = {
97
+ 'default': {
98
+ 'ENGINE': 'django.db.backends.sqlite3',
99
+ 'NAME': BASE_DIR / 'db.sqlite3',
100
+ }
101
+ }
102
+
103
+
104
+ # Password validation
105
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
106
+
107
+ AUTH_PASSWORD_VALIDATORS = [
108
+ {
109
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
110
+ },
111
+ {
112
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
113
+ 'OPTIONS': {
114
+ 'min_length': 8,
115
+ }
116
+ },
117
+ {
118
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
119
+ },
120
+ {
121
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
122
+ },
123
+ ]
124
+
125
+
126
+ # Internationalization
127
+ # https://docs.djangoproject.com/en/5.2/topics/i18n/
128
+
129
+ LANGUAGE_CODE = 'fr-fr'
130
+
131
+ TIME_ZONE = 'UTC'
132
+
133
+ USE_I18N = True
134
+
135
+ USE_TZ = True
136
+
137
+
138
+ # Static files (CSS, JavaScript, Images)
139
+ # https://docs.djangoproject.com/en/5.2/howto/static-files/
140
+
141
+ STATIC_URL = '/static/'
142
+ STATIC_ROOT = BASE_DIR / 'static'
143
+ STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
144
+
145
+ MEDIA_URL = '/media/'
146
+ MEDIA_ROOT = BASE_DIR / 'media'
147
+
148
+ # Default primary key field type
149
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
150
+
151
+ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
152
+
153
+
154
+ AUTH_USER_MODEL = 'api.User'
155
+
156
+
157
+ # REST Framework Configuration
158
+ REST_FRAMEWORK = {
159
+ 'DEFAULT_AUTHENTICATION_CLASSES': [
160
+ 'rest_framework_simplejwt.authentication.JWTAuthentication',
161
+ ],
162
+ 'DEFAULT_PERMISSION_CLASSES': [
163
+ 'rest_framework.permissions.IsAuthenticated',
164
+ ],
165
+ 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
166
+ 'PAGE_SIZE': 20,
167
+ 'DEFAULT_FILTER_BACKENDS': [
168
+ 'django_filters.rest_framework.DjangoFilterBackend',
169
+ 'rest_framework.filters.SearchFilter',
170
+ 'rest_framework.filters.OrderingFilter',
171
+ ],
172
+ 'DEFAULT_THROTTLE_CLASSES': [
173
+ 'rest_framework.throttling.AnonRateThrottle',
174
+ 'rest_framework.throttling.UserRateThrottle'
175
+ ],
176
+ 'DEFAULT_THROTTLE_RATES': {
177
+ 'anon': '100/hour',
178
+ 'user': '1000/hour'
179
+ },
180
+ 'EXCEPTION_HANDLER': 'api.exceptions.custom_exception_handler',
181
+ }
182
+
183
+
184
+ # Simple JWT Configuration
185
+ SIMPLE_JWT = {
186
+ 'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
187
+ 'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
188
+ 'ROTATE_REFRESH_TOKENS': True,
189
+ 'BLACKLIST_AFTER_ROTATION': True,
190
+ 'UPDATE_LAST_LOGIN': True,
191
+
192
+ 'ALGORITHM': 'HS256',
193
+ 'SIGNING_KEY': SECRET_KEY,
194
+ 'VERIFYING_KEY': None,
195
+ 'AUDIENCE': None,
196
+ 'ISSUER': None,
197
+
198
+ 'AUTH_HEADER_TYPES': ('Bearer',),
199
+ 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
200
+ 'USER_ID_FIELD': 'id',
201
+ 'USER_ID_CLAIM': 'user_id',
202
+
203
+ 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
204
+ 'TOKEN_TYPE_CLAIM': 'token_type',
205
+ }
206
+
207
+
208
+ # CORS Configuration
209
+ CORS_ALLOWED_ORIGINS = config(
210
+ 'CORS_ALLOWED_ORIGINS',
211
+ default='http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173,https://akompta-ai-flame.vercel.app,https://cosmolabhub-akomptabackend.hf.space',
212
+ cast=Csv()
213
+ )
214
+
215
+ CORS_ALLOW_CREDENTIALS = True
216
+
217
+ CORS_ALLOW_METHODS = [
218
+ 'DELETE',
219
+ 'GET',
220
+ 'OPTIONS',
221
+ 'PATCH',
222
+ 'POST',
223
+ 'PUT',
224
+ ]
225
+
226
+ CORS_ALLOW_HEADERS = [
227
+ 'accept',
228
+ 'accept-encoding',
229
+ 'authorization',
230
+ 'content-type',
231
+ 'dnt',
232
+ 'origin',
233
+ 'user-agent',
234
+ 'x-csrftoken',
235
+ 'x-requested-with',
236
+ ]
237
+
238
+
239
+ # File Upload Settings
240
+ FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB
241
+ DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB
242
+
243
+ # Allowed image formats
244
+ ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
245
+ MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
246
+
247
+
248
+ # Email Configuration (pour password reset)
249
+ EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
250
+ EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.gmail.com')
251
+ EMAIL_PORT = int(os.environ.get('EMAIL_PORT', '587'))
252
+ EMAIL_USE_TLS = True
253
+ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
254
+ EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
255
+ DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@akompta.com')
256
+
257
+
258
+ # Security Settings for Production
259
+ if not DEBUG:
260
+ # IMPORTANT: Ne pas activer SECURE_SSL_REDIRECT sur Hugging Face Spaces
261
+ # Le reverse proxy de HF gère déjà HTTPS, activer cette option cause une boucle de redirection
262
+ SECURE_SSL_REDIRECT = False
263
+
264
+ # Permet à Django de reconnaître les requêtes HTTPS via le proxy
265
+ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
266
+
267
+ SESSION_COOKIE_SECURE = True
268
+ CSRF_COOKIE_SECURE = True
269
+ SECURE_BROWSER_XSS_FILTER = True
270
+ SECURE_CONTENT_TYPE_NOSNIFF = True
271
+ X_FRAME_OPTIONS = 'DENY'
272
+ SECURE_HSTS_SECONDS = 31536000
273
+ SECURE_HSTS_INCLUDE_SUBDOMAINS = True
274
+ SECURE_HSTS_PRELOAD = True
275
+
276
+
277
+ # Logging Configuration
278
+ LOGGING = {
279
+ 'version': 1,
280
+ 'disable_existing_loggers': False,
281
+ 'formatters': {
282
+ 'verbose': {
283
+ 'format': '{levelname} {asctime} {module} {message}',
284
+ 'style': '{',
285
+ },
286
+ },
287
+ 'handlers': {
288
+ 'console': {
289
+ 'class': 'logging.StreamHandler',
290
+ 'formatter': 'verbose',
291
+ },
292
+ 'file': {
293
+ 'class': 'logging.FileHandler',
294
+ 'filename': BASE_DIR / 'logs' / 'django.log',
295
+ 'formatter': 'verbose',
296
+ },
297
+ },
298
+ 'root': {
299
+ 'handlers': ['console', 'file'],
300
+ 'level': 'INFO',
301
+ },
302
+ 'loggers': {
303
+ 'django': {
304
+ 'handlers': ['console', 'file'],
305
+ 'level': 'INFO',
306
+ 'propagate': False,
307
+ },
308
+ },
309
+ }
Akompta/urls.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ URLs principales du projet Akompta
3
+ """
4
+ from django.contrib import admin
5
+ from django.urls import path, include
6
+ from django.conf import settings
7
+ from django.conf.urls.static import static
8
+
9
+ urlpatterns = [
10
+ path('admin/', admin.site.urls),
11
+ path('api/', include('api.urls')),
12
+ ]
13
+
14
+ # Servir les fichiers media en développement
15
+ if settings.DEBUG:
16
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
17
+ urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
18
+
19
+ # Configuration de l'admin
20
+ admin.site.site_header = "Akompta AI Administration"
21
+ admin.site.site_title = "Akompta Admin"
22
+ admin.site.index_title = "Bienvenue sur l'administration Akompta"
Akompta/wsgi.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WSGI config for Akompta project.
3
+
4
+ It exposes the WSGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.wsgi import get_wsgi_application
13
+
14
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Akompta.settings')
15
+
16
+ application = get_wsgi_application()
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.11-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE 1
6
+ ENV PYTHONUNBUFFERED 1
7
+ ENV PORT 7860
8
+
9
+ # Set work directory
10
+ WORKDIR /app
11
+
12
+ # Install system dependencies
13
+ RUN apt-get update && apt-get install -y \
14
+ build-essential \
15
+ libpq-dev \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Install Python dependencies
19
+ COPY requirements.txt /app/
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Copy project
23
+ COPY . /app/
24
+
25
+ # Create a non-root user and switch to it
26
+ # Hugging Face Spaces uses user 1000
27
+ RUN useradd -m -u 1000 user
28
+ RUN chown -R user:user /app
29
+ USER user
30
+
31
+ # Create necessary directories
32
+ RUN mkdir -p /app/logs /app/media /app/static
33
+
34
+ # Expose the port Hugging Face expects
35
+ EXPOSE 7860
36
+
37
+ # Run the application
38
+ CMD ["./start.sh"]
Documentation/API_EXEMPLES.MD ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Exemples d'Utilisation de l'API Akompta
2
+
3
+ ## 🔐 Authentification
4
+
5
+ ### Inscription (Compte Personnel)
6
+
7
+ ```bash
8
+ curl -X POST http://localhost:8000/api/auth/register/ \
9
+ -H "Content-Type: application/json" \
10
+ -d '{
11
+ "email": "user@example.com",
12
+ "password": "SecurePass123!",
13
+ "password2": "SecurePass123!",
14
+ "first_name": "John",
15
+ "last_name": "Doe",
16
+ "phone_number": "+22890123456",
17
+ "account_type": "personal",
18
+ "agreed": true
19
+ }'
20
+ ```
21
+
22
+ **Réponse :**
23
+ ```json
24
+ {
25
+ "user": {
26
+ "id": 1,
27
+ "email": "user@example.com",
28
+ "first_name": "John",
29
+ "last_name": "Doe",
30
+ "account_type": "personal",
31
+ "is_premium": false
32
+ },
33
+ "tokens": {
34
+ "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...",
35
+ "access": "eyJ0eXAiOiJKV1QiLCJhbGc..."
36
+ }
37
+ }
38
+ ```
39
+
40
+ ### Inscription (Compte Business)
41
+
42
+ ```bash
43
+ curl -X POST http://localhost:8000/api/auth/register/ \
44
+ -H "Content-Type: application/json" \
45
+ -d '{
46
+ "email": "business@example.com",
47
+ "password": "SecurePass123!",
48
+ "password2": "SecurePass123!",
49
+ "first_name": "Jane",
50
+ "last_name": "Smith",
51
+ "phone_number": "+22890987654",
52
+ "account_type": "business",
53
+ "business_name": "AgriTech Solutions",
54
+ "sector": "Agriculture",
55
+ "location": "Lomé, Togo",
56
+ "ifu": "1234567890123",
57
+ "agreed": true,
58
+ "businessAgreed": true
59
+ }'
60
+ ```
61
+
62
+ ### Connexion
63
+
64
+ ```bash
65
+ curl -X POST http://localhost:8000/api/auth/login/ \
66
+ -H "Content-Type: application/json" \
67
+ -d '{
68
+ "email": "user@example.com",
69
+ "password": "SecurePass123!"
70
+ }'
71
+ ```
72
+
73
+ ### Rafraîchir le Token
74
+
75
+ ```bash
76
+ curl -X POST http://localhost:8000/api/auth/token/refresh/ \
77
+ -H "Content-Type: application/json" \
78
+ -d '{
79
+ "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..."
80
+ }'
81
+ ```
82
+
83
+ ### Récupérer le Profil
84
+
85
+ ```bash
86
+ curl -X GET http://localhost:8000/api/auth/me/ \
87
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
88
+ ```
89
+
90
+ ### Modifier le Profil
91
+
92
+ ```bash
93
+ curl -X PATCH http://localhost:8000/api/auth/me/ \
94
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
95
+ -H "Content-Type: application/json" \
96
+ -d '{
97
+ "phone_number": "+22890999888",
98
+ "dark_mode": true
99
+ }'
100
+ ```
101
+
102
+ ### Changer le Mot de Passe
103
+
104
+ ```bash
105
+ curl -X POST http://localhost:8000/api/auth/change-password/ \
106
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
107
+ -H "Content-Type: application/json" \
108
+ -d '{
109
+ "old_password": "SecurePass123!",
110
+ "new_password": "NewSecurePass456!",
111
+ "new_password2": "NewSecurePass456!"
112
+ }'
113
+ ```
114
+
115
+ ## 📦 Produits
116
+
117
+ ### Créer un Produit
118
+
119
+ ```bash
120
+ curl -X POST http://localhost:8000/api/products/ \
121
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
122
+ -H "Content-Type: application/json" \
123
+ -d '{
124
+ "name": "Tomates",
125
+ "description": "Tomates fraîches de qualité",
126
+ "price": "800.00",
127
+ "unit": "Kg",
128
+ "category": "vente",
129
+ "stock_status": "ok"
130
+ }'
131
+ ```
132
+
133
+ ### Créer un Produit avec Image
134
+
135
+ ```bash
136
+ curl -X POST http://localhost:8000/api/products/ \
137
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
138
+ -F "name=Tomates" \
139
+ -F "description=Tomates fraîches" \
140
+ -F "price=800.00" \
141
+ -F "unit=Kg" \
142
+ -F "category=vente" \
143
+ -F "stock_status=ok" \
144
+ -F "image=@/path/to/image.jpg"
145
+ ```
146
+
147
+ ### Lister les Produits
148
+
149
+ ```bash
150
+ # Tous les produits
151
+ curl -X GET http://localhost:8000/api/products/ \
152
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
153
+
154
+ # Filtrer par catégorie
155
+ curl -X GET "http://localhost:8000/api/products/?category=vente" \
156
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
157
+
158
+ # Rechercher
159
+ curl -X GET "http://localhost:8000/api/products/?search=tomate" \
160
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
161
+
162
+ # Pagination
163
+ curl -X GET "http://localhost:8000/api/products/?page=2" \
164
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
165
+ ```
166
+
167
+ ### Modifier un Produit
168
+
169
+ ```bash
170
+ curl -X PUT http://localhost:8000/api/products/1/ \
171
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
172
+ -H "Content-Type: application/json" \
173
+ -d '{
174
+ "name": "Tomates Premium",
175
+ "price": "1000.00",
176
+ "stock_status": "low"
177
+ }'
178
+ ```
179
+
180
+ ### Supprimer un Produit
181
+
182
+ ```bash
183
+ curl -X DELETE http://localhost:8000/api/products/1/ \
184
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
185
+ ```
186
+
187
+ ### Exporter les Produits en CSV
188
+
189
+ ```bash
190
+ curl -X GET http://localhost:8000/api/products/export/ \
191
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
192
+ -o products.csv
193
+ ```
194
+
195
+ ## 💰 Transactions
196
+
197
+ ### Créer une Transaction
198
+
199
+ ```bash
200
+ curl -X POST http://localhost:8000/api/transactions/ \
201
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
202
+ -H "Content-Type: application/json" \
203
+ -d '{
204
+ "name": "Vente de tomates",
205
+ "amount": "5000.00",
206
+ "type": "income",
207
+ "category": "Ventes",
208
+ "date": "2024-11-27T10:30:00Z",
209
+ "currency": "FCFA"
210
+ }'
211
+ ```
212
+
213
+ ### Lister les Transactions
214
+
215
+ ```bash
216
+ # Toutes les transactions
217
+ curl -X GET http://localhost:8000/api/transactions/ \
218
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
219
+
220
+ # Filtrer par type
221
+ curl -X GET "http://localhost:8000/api/transactions/?type=income" \
222
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
223
+
224
+ # Filtrer par période
225
+ curl -X GET "http://localhost:8000/api/transactions/?date_range=week" \
226
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
227
+
228
+ # Trier par date décroissante
229
+ curl -X GET "http://localhost:8000/api/transactions/?ordering=-date" \
230
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
231
+
232
+ # Rechercher
233
+ curl -X GET "http://localhost:8000/api/transactions/?search=vente" \
234
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
235
+ ```
236
+
237
+ ### Résumé des Transactions (Dashboard)
238
+
239
+ ```bash
240
+ curl -X GET http://localhost:8000/api/transactions/summary/ \
241
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
242
+ ```
243
+
244
+ **Réponse :**
245
+ ```json
246
+ {
247
+ "balance": "125000.00",
248
+ "income_24h": "15000.00",
249
+ "expenses_24h": "8000.00",
250
+ "income_variation": 12.5,
251
+ "expenses_variation": -5.3
252
+ }
253
+ ```
254
+
255
+ ## 📊 Analytics
256
+
257
+ ### Overview (Graphique en Barres)
258
+
259
+ ```bash
260
+ curl -X GET http://localhost:8000/api/analytics/overview/ \
261
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
262
+ ```
263
+
264
+ **Réponse :**
265
+ ```json
266
+ [
267
+ {
268
+ "month": "Oct 2024",
269
+ "income": "85000.00",
270
+ "expenses": "45000.00"
271
+ },
272
+ {
273
+ "month": "Nov 2024",
274
+ "income": "120000.00",
275
+ "expenses": "65000.00"
276
+ }
277
+ ]
278
+ ```
279
+
280
+ ### Breakdown (Graphique Camembert)
281
+
282
+ ```bash
283
+ curl -X GET http://localhost:8000/api/analytics/breakdown/ \
284
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
285
+ ```
286
+
287
+ **Réponse :**
288
+ ```json
289
+ [
290
+ {
291
+ "category": "Transport",
292
+ "amount": "25000.00",
293
+ "percentage": 38.5
294
+ },
295
+ {
296
+ "category": "Loyer",
297
+ "amount": "40000.00",
298
+ "percentage": 61.5
299
+ }
300
+ ]
301
+ ```
302
+
303
+ ### KPIs
304
+
305
+ ```bash
306
+ curl -X GET http://localhost:8000/api/analytics/kpi/ \
307
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
308
+ ```
309
+
310
+ **Réponse :**
311
+ ```json
312
+ {
313
+ "average_basket": "12500.00",
314
+ "estimated_mrr": "120000.00",
315
+ "cac": "5000.00"
316
+ }
317
+ ```
318
+
319
+ ## 🎯 Budgets
320
+
321
+ ### Créer un Budget
322
+
323
+ ```bash
324
+ curl -X POST http://localhost:8000/api/budgets/ \
325
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
326
+ -H "Content-Type: application/json" \
327
+ -d '{
328
+ "category": "Transport",
329
+ "limit": "50000.00",
330
+ "color": "#3B82F6"
331
+ }'
332
+ ```
333
+
334
+ ### Lister les Budgets
335
+
336
+ ```bash
337
+ curl -X GET http://localhost:8000/api/budgets/ \
338
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
339
+ ```
340
+
341
+ **Réponse :**
342
+ ```json
343
+ {
344
+ "count": 3,
345
+ "results": [
346
+ {
347
+ "id": 1,
348
+ "category": "Transport",
349
+ "limit": "50000.00",
350
+ "color": "#3B82F6",
351
+ "spent_amount": "32500.00",
352
+ "percentage": 65.0
353
+ }
354
+ ]
355
+ }
356
+ ```
357
+
358
+ ### Modifier un Budget
359
+
360
+ ```bash
361
+ curl -X PUT http://localhost:8000/api/budgets/1/ \
362
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
363
+ -H "Content-Type: application/json" \
364
+ -d '{
365
+ "limit": "75000.00"
366
+ }'
367
+ ```
368
+
369
+ ### Supprimer un Budget
370
+
371
+ ```bash
372
+ curl -X DELETE http://localhost:8000/api/budgets/1/ \
373
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
374
+ ```
375
+
376
+ ## 📢 Annonces
377
+
378
+ ### Lister les Annonces (Public)
379
+
380
+ ```bash
381
+ curl -X GET http://localhost:8000/api/ads/
382
+ ```
383
+
384
+ ### Créer une Annonce
385
+
386
+ ```bash
387
+ curl -X POST http://localhost:8000/api/ads/ \
388
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
389
+ -F "product_name=Engrais Bio" \
390
+ -F "owner_name=FertiTogo" \
391
+ -F "description=Engrais biologique de qualité" \
392
+ -F "whatsapp=+22890123456" \
393
+ -F "location=Lomé, Togo" \
394
+ -F "website=https://fertitogo.com" \
395
+ -F "image=@/path/to/image.jpg"
396
+ ```
397
+
398
+ ### Rechercher des Annonces
399
+
400
+ ```bash
401
+ curl -X GET "http://localhost:8000/api/ads/?search=engrais" \
402
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
403
+ ```
404
+
405
+ ## ⚠️ Gestion des Erreurs
406
+
407
+ ### Erreur de Validation
408
+
409
+ ```json
410
+ {
411
+ "type": "validation_error",
412
+ "errors": {
413
+ "email": ["Cet email existe déjà."],
414
+ "ifu": ["Ce champ est obligatoire pour les comptes professionnels."]
415
+ }
416
+ }
417
+ ```
418
+
419
+ ### Erreur d'Authentification
420
+
421
+ ```json
422
+ {
423
+ "type": "authentication_error",
424
+ "message": "Token invalide ou expiré"
425
+ }
426
+ ```
427
+
428
+ ### Erreur de Permission
429
+
430
+ ```json
431
+ {
432
+ "type": "permission_error",
433
+ "message": "Vous n'avez pas la permission d'effectuer cette action"
434
+ }
435
+ ```
436
+
437
+ ## 📝 Notes Importantes
438
+
439
+ 1. **Toujours inclure le token** dans l'en-tête `Authorization: Bearer YOUR_TOKEN`
440
+ 2. **Les dates** doivent être au format ISO 8601 : `2024-11-27T10:30:00Z`
441
+ 3. **Les montants** sont en format décimal avec 2 décimales : `"1000.00"`
442
+ 4. **Pagination** : Paramètres `?page=2&page_size=20`
443
+ 5. **Upload de fichiers** : Utiliser `multipart/form-data` avec `-F`
444
+
445
+ ## 🔄 Exemple Complet de Workflow
446
+
447
+ ```bash
448
+ # 1. S'inscrire
449
+ TOKEN=$(curl -X POST http://localhost:8000/api/auth/register/ \
450
+ -H "Content-Type: application/json" \
451
+ -d '{"email":"test@example.com","password":"Test123!","password2":"Test123!","first_name":"Test","last_name":"User","account_type":"personal","agreed":true}' \
452
+ | jq -r '.tokens.access')
453
+
454
+ # 2. Créer un produit
455
+ curl -X POST http://localhost:8000/api/products/ \
456
+ -H "Authorization: Bearer $TOKEN" \
457
+ -H "Content-Type: application/json" \
458
+ -d '{"name":"Tomates","price":"800.00","unit":"Kg","category":"vente","stock_status":"ok"}'
459
+
460
+ # 3. Ajouter une transaction
461
+ curl -X POST http://localhost:8000/api/transactions/ \
462
+ -H "Authorization: Bearer $TOKEN" \
463
+ -H "Content-Type: application/json" \
464
+ -d '{"name":"Vente tomates","amount":"5000.00","type":"income","category":"Ventes","date":"2024-11-27T10:00:00Z"}'
465
+
466
+ # 4. Voir le résumé
467
+ curl -X GET http://localhost:8000/api/transactions/summary/ \
468
+ -H "Authorization: Bearer $TOKEN"
469
+ ```
README.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Akompta Backend
3
+ emoji: 💰
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
10
+
11
+ # Akompta AI - Backend API
12
+
13
+ Ceci est le backend de l'application **Akompta AI**, une plateforme de gestion financière et comptable.
14
+
15
+ ## 🚀 Déploiement sur Hugging Face Spaces
16
+
17
+ Ce backend est configuré pour s'exécuter dans un conteneur Docker.
18
+
19
+ ### Configuration requise (Variables d'environnement)
20
+
21
+ Assurez-vous de configurer les variables suivantes dans les "Settings" de votre Space :
22
+
23
+ - `DEBUG`: `False`
24
+ - `SECRET_KEY`: Votre clé secrète Django
25
+ - `ALLOWED_HOSTS`: `*`
26
+ - `CORS_ALLOWED_ORIGINS`: `https://akompta-ai-flame.vercel.app`
27
+ - `GEMINI_API_KEY`: Votre clé API Google Gemini
28
+
29
+ ## 🛠️ Technologies utilisées
30
+
31
+ - **Framework**: Django 5.2 + Django REST Framework
32
+ - **Authentification**: JWT (Simple JWT)
33
+ - **Base de données**: SQLite
34
+ - **Serveur de production**: Gunicorn
35
+ - **Statique**: WhiteNoise
36
+
37
+ ---
38
+ Développé par **Marino ATOHOUN** pour **CosmoLAB Hub**.
api/__init__.py ADDED
File without changes
api/admin.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.contrib import admin
2
+ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
3
+ from django.utils.html import format_html
4
+ from .models import User, Product, Transaction, Budget, Ad, AIInsight
5
+
6
+
7
+ @admin.register(AIInsight)
8
+ class AIInsightAdmin(admin.ModelAdmin):
9
+ """Administration des insights IA"""
10
+ list_display = ['user', 'created_at', 'context_hash']
11
+ list_filter = ['created_at', 'user']
12
+ search_fields = ['user__email', 'content']
13
+ readonly_fields = ['created_at', 'context_hash']
14
+
15
+
16
+ @admin.register(User)
17
+ class UserAdmin(BaseUserAdmin):
18
+ """Administration des utilisateurs"""
19
+
20
+ list_display = [
21
+ 'email', 'first_name', 'last_name', 'account_type',
22
+ 'is_premium', 'is_staff', 'date_joined'
23
+ ]
24
+ list_filter = ['account_type', 'is_premium', 'is_staff', 'is_active']
25
+ search_fields = ['email', 'first_name', 'last_name', 'business_name']
26
+ ordering = ['-date_joined']
27
+
28
+ fieldsets = (
29
+ ('Informations de connexion', {
30
+ 'fields': ('email', 'password')
31
+ }),
32
+ ('Informations personnelles', {
33
+ 'fields': ('first_name', 'last_name', 'phone_number', 'avatar')
34
+ }),
35
+ ('Type de compte', {
36
+ 'fields': ('account_type', 'is_premium')
37
+ }),
38
+ ('Informations Business', {
39
+ 'fields': ('business_name', 'sector', 'location', 'ifu', 'business_logo'),
40
+ 'classes': ('collapse',)
41
+ }),
42
+ ('Paramètres', {
43
+ 'fields': ('currency', 'language', 'dark_mode')
44
+ }),
45
+ ('Permissions', {
46
+ 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'),
47
+ 'classes': ('collapse',)
48
+ }),
49
+ ('Dates importantes', {
50
+ 'fields': ('last_login', 'date_joined', 'business_agreed_at'),
51
+ 'classes': ('collapse',)
52
+ }),
53
+ )
54
+
55
+ add_fieldsets = (
56
+ (None, {
57
+ 'classes': ('wide',),
58
+ 'fields': ('email', 'password1', 'password2', 'first_name', 'last_name', 'account_type'),
59
+ }),
60
+ )
61
+
62
+
63
+ @admin.register(Product)
64
+ class ProductAdmin(admin.ModelAdmin):
65
+ """Administration des produits"""
66
+
67
+ list_display = [
68
+ 'name', 'user', 'price', 'unit', 'category',
69
+ 'stock_status', 'image_preview', 'created_at'
70
+ ]
71
+ list_filter = ['category', 'stock_status', 'created_at']
72
+ search_fields = ['name', 'description', 'user__email']
73
+ ordering = ['-created_at']
74
+ readonly_fields = ['created_at', 'updated_at', 'image_preview']
75
+
76
+ fieldsets = (
77
+ ('Informations de base', {
78
+ 'fields': ('user', 'name', 'description')
79
+ }),
80
+ ('Tarification', {
81
+ 'fields': ('price', 'unit')
82
+ }),
83
+ ('Catégorisation', {
84
+ 'fields': ('category', 'stock_status')
85
+ }),
86
+ ('Image', {
87
+ 'fields': ('image', 'image_preview')
88
+ }),
89
+ ('Dates', {
90
+ 'fields': ('created_at', 'updated_at'),
91
+ 'classes': ('collapse',)
92
+ }),
93
+ )
94
+
95
+ def image_preview(self, obj):
96
+ if obj.image:
97
+ return format_html(
98
+ '<img src="{}" style="max-height: 100px; max-width: 100px;" />',
99
+ obj.image.url
100
+ )
101
+ return "Pas d'image"
102
+ image_preview.short_description = "Aperçu"
103
+
104
+
105
+ @admin.register(Transaction)
106
+ class TransactionAdmin(admin.ModelAdmin):
107
+ """Administration des transactions"""
108
+
109
+ list_display = [
110
+ 'name', 'user', 'amount', 'currency', 'type',
111
+ 'category', 'date', 'created_at'
112
+ ]
113
+ list_filter = ['type', 'category', 'date', 'created_at']
114
+ search_fields = ['name', 'user__email', 'category']
115
+ ordering = ['-date']
116
+ readonly_fields = ['created_at', 'updated_at']
117
+ date_hierarchy = 'date'
118
+
119
+ fieldsets = (
120
+ ('Utilisateur', {
121
+ 'fields': ('user',)
122
+ }),
123
+ ('Transaction', {
124
+ 'fields': ('name', 'amount', 'currency', 'type', 'category', 'date')
125
+ }),
126
+ ('Dates système', {
127
+ 'fields': ('created_at', 'updated_at'),
128
+ 'classes': ('collapse',)
129
+ }),
130
+ )
131
+
132
+ def get_queryset(self, request):
133
+ qs = super().get_queryset(request)
134
+ return qs.select_related('user')
135
+
136
+
137
+ @admin.register(Budget)
138
+ class BudgetAdmin(admin.ModelAdmin):
139
+ """Administration des budgets"""
140
+
141
+ list_display = [
142
+ 'category', 'user', 'limit', 'spent_display',
143
+ 'percentage_display', 'color_preview', 'created_at'
144
+ ]
145
+ list_filter = ['created_at']
146
+ search_fields = ['category', 'user__email']
147
+ ordering = ['-created_at']
148
+ readonly_fields = ['created_at', 'updated_at', 'spent_display', 'percentage_display']
149
+
150
+ fieldsets = (
151
+ ('Utilisateur', {
152
+ 'fields': ('user',)
153
+ }),
154
+ ('Budget', {
155
+ 'fields': ('category', 'limit', 'color')
156
+ }),
157
+ ('Statistiques', {
158
+ 'fields': ('spent_display', 'percentage_display'),
159
+ 'classes': ('collapse',)
160
+ }),
161
+ ('Dates', {
162
+ 'fields': ('created_at', 'updated_at'),
163
+ 'classes': ('collapse',)
164
+ }),
165
+ )
166
+
167
+ def spent_display(self, obj):
168
+ return f"{obj.get_spent_amount()} FCFA"
169
+ spent_display.short_description = "Montant dépensé"
170
+
171
+ def percentage_display(self, obj):
172
+ spent = obj.get_spent_amount()
173
+ if obj.limit > 0:
174
+ percentage = (spent / obj.limit) * 100
175
+ color = 'green' if percentage < 80 else 'orange' if percentage < 100 else 'red'
176
+ return format_html(
177
+ '<span style="color: {};">{}</span>',
178
+ color, f'{percentage:.1f}%'
179
+ )
180
+ return "0%"
181
+ percentage_display.short_description = "Pourcentage"
182
+
183
+ def color_preview(self, obj):
184
+ return format_html(
185
+ '<div style="width: 30px; height: 30px; background-color: {}; border: 1px solid #ccc;"></div>',
186
+ obj.color
187
+ )
188
+ color_preview.short_description = "Couleur"
189
+
190
+
191
+ @admin.register(Ad)
192
+ class AdAdmin(admin.ModelAdmin):
193
+ """Administration des annonces"""
194
+
195
+ list_display = [
196
+ 'product_name', 'owner_name', 'location',
197
+ 'is_verified', 'image_preview', 'created_at'
198
+ ]
199
+ list_filter = ['is_verified', 'created_at']
200
+ search_fields = ['product_name', 'owner_name', 'description', 'location']
201
+ ordering = ['-created_at']
202
+ readonly_fields = ['created_at', 'updated_at', 'image_preview']
203
+
204
+ fieldsets = (
205
+ ('Informations de base', {
206
+ 'fields': ('user', 'product_name', 'owner_name', 'description')
207
+ }),
208
+ ('Localisation & Contact', {
209
+ 'fields': ('location', 'whatsapp', 'website')
210
+ }),
211
+ ('Image', {
212
+ 'fields': ('image', 'image_preview')
213
+ }),
214
+ ('Modération', {
215
+ 'fields': ('is_verified',)
216
+ }),
217
+ ('Dates', {
218
+ 'fields': ('created_at', 'updated_at'),
219
+ 'classes': ('collapse',)
220
+ }),
221
+ )
222
+
223
+ actions = ['verify_ads', 'unverify_ads']
224
+
225
+ def verify_ads(self, request, queryset):
226
+ count = queryset.update(is_verified=True)
227
+ self.message_user(request, f"{count} annonce(s) vérifiée(s).")
228
+ verify_ads.short_description = "Vérifier les annonces sélectionnées"
229
+
230
+ def unverify_ads(self, request, queryset):
231
+ count = queryset.update(is_verified=False)
232
+ self.message_user(request, f"{count} annonce(s) dé-vérifiée(s).")
233
+ unverify_ads.short_description = "Retirer la vérification"
234
+
235
+ def image_preview(self, obj):
236
+ if obj.image:
237
+ return format_html(
238
+ '<img src="{}" style="max-height: 100px; max-width: 100px;" />',
239
+ obj.image.url
240
+ )
241
+ return "Pas d'image"
242
+ image_preview.short_description = "Aperçu"
243
+
244
+
245
+ # Personnalisation du site admin
246
+ admin.site.site_header = "Akompta AI Administration"
247
+ admin.site.site_title = "Akompta Admin"
248
+ admin.site.index_title = "Bienvenue sur l'administration Akompta"
api/apps.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class ApiConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'api'
api/assemblyai_service.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import requests
4
+ from pathlib import Path
5
+ from django.conf import settings
6
+
7
+ class AssemblyAIService:
8
+ def __init__(self):
9
+ # Try to load from environment first
10
+ self.api_key = os.environ.get("ASSEMBLYAI_API_KEY")
11
+
12
+ # If not in environment, try loading from .env file directly
13
+ if not self.api_key:
14
+ env_path = Path(__file__).resolve().parent.parent / '.env'
15
+ if env_path.exists():
16
+ with open(env_path, 'r') as f:
17
+ for line in f:
18
+ line = line.strip()
19
+ if line.startswith('ASSEMBLYAI_API_KEY='):
20
+ self.api_key = line.split('=', 1)[1].strip()
21
+ break
22
+
23
+ if not self.api_key:
24
+ print("Warning: ASSEMBLYAI_API_KEY not found.")
25
+
26
+ self.headers = {
27
+ "Authorization": self.api_key,
28
+ "Content-Type": "application/json"
29
+ }
30
+ self.upload_url = "https://api.assemblyai.com/v2/upload"
31
+ self.transcript_url = "https://api.assemblyai.com/v2/transcript"
32
+
33
+ def upload_file(self, audio_file):
34
+ """Upload audio file to AssemblyAI"""
35
+ if not self.api_key:
36
+ return None
37
+ try:
38
+ # AssemblyAI expects the raw file content in the body
39
+ audio_file.seek(0)
40
+ response = requests.post(
41
+ self.upload_url,
42
+ headers={"Authorization": self.api_key},
43
+ data=audio_file.read()
44
+ )
45
+ if response.status_code != 200:
46
+ print(f"AssemblyAI Upload Error: {response.status_code} - {response.text}")
47
+ response.raise_for_status()
48
+ return response.json()["upload_url"]
49
+ except Exception as e:
50
+ print(f"Error uploading to AssemblyAI: {e}")
51
+ return None
52
+
53
+ def transcribe(self, audio_file, language=None):
54
+ """
55
+ Transcribe audio file using AssemblyAI REST API.
56
+ """
57
+ if not self.api_key:
58
+ return None
59
+
60
+ try:
61
+ # 1. Upload
62
+ upload_url = self.upload_file(audio_file)
63
+ if not upload_url:
64
+ return None
65
+
66
+ # Start transcription with required speech model
67
+ lang_to_use = language.lower()[:2] if language else "fr"
68
+ payload = {
69
+ "audio_url": upload_url,
70
+ "language_code": lang_to_use,
71
+ "speech_models": ["universal-3-pro"] # Matches the error suggestion
72
+ }
73
+
74
+ response = requests.post(self.transcript_url, json=payload, headers=self.headers)
75
+ if response.status_code != 200 and response.status_code != 201:
76
+ print(f"AssemblyAI error response: {response.text}")
77
+ response.raise_for_status()
78
+
79
+ transcript_id = response.json()["id"]
80
+
81
+ # 3. Polling for results
82
+ polling_url = f"{self.transcript_url}/{transcript_id}"
83
+
84
+ max_attempts = 30
85
+ attempts = 0
86
+ while attempts < max_attempts:
87
+ polling_response = requests.get(polling_url, headers=self.headers)
88
+ polling_response.raise_for_status()
89
+ data = polling_response.json()
90
+
91
+ if data["status"] == "completed":
92
+ return data["text"]
93
+ elif data["status"] == "error":
94
+ print(f"AssemblyAI transcription error: {data.get('error')}")
95
+ return None
96
+
97
+ attempts += 1
98
+ time.sleep(1) # Poll every second
99
+
100
+ print("AssemblyAI transcription timed out.")
101
+ return None
102
+
103
+ except Exception as e:
104
+ print(f"Error calling AssemblyAI STT: {e}")
105
+ return None
api/exceptions.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from rest_framework.views import exception_handler
2
+ from rest_framework.exceptions import ValidationError
3
+ from rest_framework.response import Response
4
+
5
+
6
+ def custom_exception_handler(exc, context):
7
+ """
8
+ Handler personnalisé pour formater les erreurs selon la spec du frontend
9
+ Format attendu:
10
+ {
11
+ "type": "validation_error",
12
+ "errors": {
13
+ "field_name": ["Error message"]
14
+ }
15
+ }
16
+ """
17
+ # Appeler le handler par défaut de DRF
18
+ response = exception_handler(exc, context)
19
+
20
+ if response is not None:
21
+ # Formater les erreurs de validation
22
+ if isinstance(exc, ValidationError):
23
+ custom_response = {
24
+ 'type': 'validation_error',
25
+ 'errors': response.data
26
+ }
27
+ response.data = custom_response
28
+
29
+ # Pour les autres erreurs, ajouter un type
30
+ else:
31
+ error_type = 'error'
32
+ if response.status_code == 401:
33
+ error_type = 'authentication_error'
34
+ elif response.status_code == 403:
35
+ error_type = 'permission_error'
36
+ elif response.status_code == 404:
37
+ error_type = 'not_found_error'
38
+ elif response.status_code >= 500:
39
+ error_type = 'server_error'
40
+
41
+ custom_response = {
42
+ 'type': error_type,
43
+ 'message': response.data.get('detail', str(exc))
44
+ }
45
+ response.data = custom_response
46
+
47
+ return response
api/gemini_service.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from pathlib import Path
4
+ from google import genai
5
+ from google.genai import types
6
+ from django.conf import settings
7
+
8
+ class GeminiService:
9
+ def __init__(self):
10
+ # Try to load from environment first
11
+ self.api_key = os.environ.get("GEMINI_API_KEY")
12
+
13
+ # If not in environment, try loading from .env file directly
14
+ if not self.api_key:
15
+ env_path = Path(__file__).resolve().parent.parent / '.env'
16
+ if env_path.exists():
17
+ with open(env_path, 'r') as f:
18
+ for line in f:
19
+ line = line.strip()
20
+ if line.startswith('GEMINI_API_KEY='):
21
+ self.api_key = line.split('=', 1)[1].strip()
22
+ break
23
+
24
+ if not self.api_key or self.api_key == 'your-gemini-api-key-here':
25
+ raise ValueError("GEMINI_API_KEY not found or invalid. Please set it in backend/.env file")
26
+
27
+ self.client = genai.Client(api_key=self.api_key)
28
+ # Use free tier model with generous quotas
29
+ self.model = "gemini-1.5-flash-latest" # Stable and standard model name
30
+
31
+ def process_voice_command(self, audio_bytes, mime_type="audio/mp3", context_products=None):
32
+ """
33
+ Process audio bytes to extract transaction details.
34
+ Returns a dictionary with transcription and structured data.
35
+ """
36
+ if context_products is None:
37
+ context_products = []
38
+
39
+ prompt = f"""
40
+ You are an AI assistant for a financial app called Akompta.
41
+ Your task is to listen to the user's voice command and extract transaction details.
42
+
43
+ The user might say things like:
44
+ - "J'ai vendu la tomate pour 500FCFA le Kilo" (Income)
45
+ - "J'ai payé un ordinateur à 300000FCFA" (Expense)
46
+
47
+ Here is the list of products currently in the user's inventory:
48
+ {json.dumps(context_products)}
49
+
50
+ If the user mentions a product from this list but doesn't specify the price, use the price from the list to calculate the total amount (amount = quantity * price).
51
+
52
+ Please perform the following:
53
+ 1. Transcribe the audio exactly as spoken (in French).
54
+ 2. Analyze the intent and extract structured data.
55
+
56
+ Return ONLY a JSON object with the following structure:
57
+ {{
58
+ "transcription": "The exact transcription",
59
+ "intent": "create_transaction",
60
+ "data": {{
61
+ "type": "income" or "expense",
62
+ "amount": number (e.g. 500),
63
+ "currency": "FCFA" or other,
64
+ "category": "Category name (e.g. Vente, Alimentation, Transport, Technologie)",
65
+ "name": "Description of the item or service (e.g. 'Vente de 3 Mangues')",
66
+ "date": "YYYY-MM-DD" or null if not specified (assume today if null)
67
+ }}
68
+ }}
69
+
70
+ Important: If a product price is found in the inventory list, ALWAYS calculate: amount = quantity * unit_price.
71
+
72
+ If the audio is not in French or English, or is not clear or not related to a transaction, return:
73
+ {{
74
+ "transcription": "...",
75
+ "intent": "unknown",
76
+ "error": "The detected language is not supported. Please speak in French or English."
77
+ }}
78
+ """
79
+
80
+ try:
81
+ response = self.client.models.generate_content(
82
+ model=self.model,
83
+ contents=[
84
+ types.Content(
85
+ parts=[
86
+ types.Part.from_bytes(data=audio_bytes, mime_type=mime_type),
87
+ types.Part.from_text(text=prompt)
88
+ ]
89
+ )
90
+ ],
91
+ config=types.GenerateContentConfig(
92
+ response_mime_type="application/json"
93
+ )
94
+ )
95
+
96
+ result = json.loads(response.text)
97
+ print(f"Gemini AI Voice Result: {result}")
98
+ return result
99
+
100
+ except Exception as e:
101
+ print(f"Error calling Gemini: {e}")
102
+ return {
103
+ "transcription": "",
104
+ "intent": "error",
105
+ "error": str(e)
106
+ }
107
+
108
+ def process_text_command(self, text, context_products=None):
109
+ """
110
+ Process text input to extract transaction details.
111
+ """
112
+ if context_products is None:
113
+ context_products = []
114
+
115
+ prompt = f"""
116
+ You are an AI assistant for a financial app called Akompta.
117
+ Your task is to analyze the user's text command and extract transaction details.
118
+
119
+ The user might say things like:
120
+ - "J'ai vendu la tomate pour 500FCFA le Kilo" (Income)
121
+ - "J'ai payé un ordinateur à 300000FCFA" (Expense)
122
+ - "Ajoute un produit Tomate à 200FCFA le bol, j'en ai 30 en stock" (Create Product)
123
+
124
+ Here is the list of products currently in the user's inventory:
125
+ {json.dumps(context_products)}
126
+
127
+ If the user mentions a product from this list but doesn't specify the price, use the price from the list to calculate the total amount (amount = quantity * price).
128
+ Example: If "Mangue" is in the list at 100 FCFA and the user says "vendu 3 mangues", the amount should be 300.
129
+
130
+ Please perform the following:
131
+ 1. Analyze the intent and extract structured data.
132
+
133
+ Return ONLY a JSON object with the following structure:
134
+
135
+ For transactions:
136
+ {{
137
+ "transcription": "The input text",
138
+ "intent": "create_transaction",
139
+ "data": {{
140
+ "type": "income" or "expense",
141
+ "amount": number,
142
+ "currency": "FCFA",
143
+ "category": "Category name",
144
+ "name": "Description (e.g. 'Vente de 3 Mangues')",
145
+ "date": "YYYY-MM-DD"
146
+ }}
147
+ }}
148
+
149
+ Important: If a product price is found in the inventory list, ALWAYS calculate: amount = quantity * unit_price.
150
+
151
+ For products/inventory:
152
+ {{
153
+ "transcription": "The input text",
154
+ "intent": "create_product",
155
+ "data": {{
156
+ "name": "Product name",
157
+ "price": number,
158
+ "unit": "Unit (e.g. bol, kg, unit)",
159
+ "description": "Short description",
160
+ "category": "vente" or "depense" or "stock",
161
+ "stock_status": "ok" or "low" or "rupture"
162
+ }}
163
+ }}
164
+
165
+ If the text is not in French or English, or is not clear, return:
166
+ {{
167
+ "transcription": "...",
168
+ "intent": "unknown",
169
+ "error": "The language is not supported. Please use French or English."
170
+ }}
171
+ """
172
+
173
+ try:
174
+ response = self.client.models.generate_content(
175
+ model=self.model,
176
+ contents=[
177
+ types.Content(
178
+ parts=[
179
+ types.Part.from_text(text=f"User command: {text}"),
180
+ types.Part.from_text(text=prompt)
181
+ ]
182
+ )
183
+ ],
184
+ config=types.GenerateContentConfig(
185
+ response_mime_type="application/json"
186
+ )
187
+ )
188
+
189
+ result = json.loads(response.text)
190
+ print(f"Gemini AI Result: {result}")
191
+ # Ensure transcription is the input text if not provided by AI
192
+ if not result.get('transcription'):
193
+ result['transcription'] = text
194
+ return result
195
+
196
+ except Exception as e:
197
+ print(f"Error calling Gemini: {e}")
198
+ return {
199
+ "transcription": text,
200
+ "intent": "error",
201
+ "error": str(e)
202
+ }
203
+
204
+ def process_insights(self, context_data):
205
+ """
206
+ Generate financial insights based on context data.
207
+ """
208
+ prompt = f"""
209
+ Tu es un analyste financier expert pour l'application Akompta.
210
+ Analyse les données suivantes (JSON) :
211
+ {json.dumps(context_data)}
212
+
213
+ Génère exactement 3 insights courts et percutants (max 1 phrase chacun) en Français :
214
+ 1. Une observation sur les ventes ou revenus.
215
+ 2. Une observation sur les dépenses.
216
+ 3. Une alerte sur le stock ou une recommandation.
217
+
218
+ Format de réponse attendu : Une liste simple de 3 phrases séparées par des sauts de ligne. Pas de markdown complexe, pas de titres.
219
+ """
220
+
221
+ try:
222
+ response = self.client.models.generate_content(
223
+ model=self.model,
224
+ contents=prompt
225
+ )
226
+
227
+ text = response.text or ""
228
+ items = [line.strip() for line in text.split('\n') if line.strip()]
229
+ return items[:3]
230
+
231
+ except Exception as e:
232
+ print(f"Error calling Gemini for insights: {e}")
233
+ return [
234
+ "Analyse des ventes en cours...",
235
+ "Vérification des stocks...",
236
+ "Calcul des marges..."
237
+ ]
api/groq_service.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from pathlib import Path
4
+ from groq import Groq
5
+ from django.conf import settings
6
+
7
+ class GroqService:
8
+ def __init__(self):
9
+ # Try to load from environment first
10
+ self.api_key = os.environ.get("GROQ_API_KEY")
11
+
12
+ # If not in environment, try loading from .env file directly
13
+ if not self.api_key:
14
+ env_path = Path(__file__).resolve().parent.parent / '.env'
15
+ if env_path.exists():
16
+ with open(env_path, 'r') as f:
17
+ for line in f:
18
+ line = line.strip()
19
+ if line.startswith('GROQ_API_KEY='):
20
+ self.api_key = line.split('=', 1)[1].strip()
21
+ break
22
+
23
+ if not self.api_key or self.api_key == 'your-groq-api-key-here':
24
+ # Note: We fallback to 'your-groq-api-key-here' to avoid crashing if it's in .env as a placeholder
25
+ print("Warning: GROQ_API_KEY not found or invalid.")
26
+
27
+ self.client = Groq(api_key=self.api_key)
28
+ self.model = "whisper-large-v3-turbo"
29
+
30
+ def transcribe(self, audio_file, language=None):
31
+ """
32
+ Transcribe audio file using Groq's Whisper API.
33
+ audio_file can be a file-like object or a path.
34
+ """
35
+ try:
36
+ # Groq expects a file object or a tuple (filename, content, content_type)
37
+ # For Django's UploadedFile, passing (file.name, file.read()) works best
38
+ file_tuple = (audio_file.name, audio_file.read())
39
+
40
+ # Context prompt to help Whisper with terminology and language detection
41
+ context_prompt = "Ceci est une commande vocale pour l'application financière Akompta. L'utilisateur enregistre ses ventes, ses achats ou ses stocks. Ex: 'J'ai vendu 2 kilos de tomates', 'Paiement fournisseur', 'Ajouter du sucre au stock'."
42
+
43
+ params = {
44
+ "file": file_tuple,
45
+ "model": self.model,
46
+ "response_format": "json",
47
+ "temperature": 0.0,
48
+ "prompt": context_prompt
49
+ }
50
+
51
+ # Use 'fr' by default if no language is specified, to avoid wrong auto-detection
52
+ if language:
53
+ params["language"] = language.lower()[:2] # e.g. 'fr' or 'en'
54
+ else:
55
+ params["language"] = "fr" # Default to French for this app context
56
+
57
+ transcription = self.client.audio.transcriptions.create(**params)
58
+ return transcription.text
59
+ except Exception as e:
60
+ print(f"Error calling Groq STT: {e}")
61
+ return None
62
+
63
+ def process_text_command(self, text, context_products=None, model="llama-3.3-70b-versatile"):
64
+ """
65
+ Process text command using Groq's LLM models.
66
+ """
67
+ if context_products is None:
68
+ context_products = []
69
+
70
+ system_prompt = f"""
71
+ You are an AI assistant for Akompta, a financial and inventory management app.
72
+ Your task is to identify if the user wants to record a financial transaction (income/expense) or manage their inventory (create/update a product).
73
+
74
+ RULES:
75
+ 1. If the user reports a SALE or PURCHASE of an item, it's a 'create_transaction'.
76
+ 2. If the user says they want to ADD, REGISTER, or CREATE an item in their catalog/stock, it's a 'create_product'.
77
+ 3. For 'create_transaction':
78
+ - type: 'income' for sales, 'expense' for purchases/costs.
79
+ - category: Use a descriptive name like 'Vente', 'Achat', 'Nourriture', etc.
80
+ - name: A SHORT and DESCRIPTIVE name of the transaction (ex: 'Vente de Savon', 'Achat de Sac de Riz').
81
+
82
+ 4. For 'create_product':
83
+ - name: The name of the product.
84
+ - category: MUST BE exactly one of: 'vente', 'depense', 'stock'.
85
+ - stock_status: MUST BE exactly one of: 'ok', 'low', 'rupture'.
86
+
87
+ Inventory Context (Existing Products):
88
+ {json.dumps(context_products)}
89
+
90
+ Return ONLY a JSON object with this EXACT structure:
91
+
92
+ If intent is 'create_transaction':
93
+ {{
94
+ "transcription": "...",
95
+ "intent": "create_transaction",
96
+ "data": {{
97
+ "type": "income" or "expense",
98
+ "amount": number,
99
+ "currency": "FCFA",
100
+ "category": "Descriptive category",
101
+ "name": "Descriptive name",
102
+ "date": "YYYY-MM-DD"
103
+ }}
104
+ }}
105
+
106
+ If intent is 'create_product':
107
+ {{
108
+ "transcription": "...",
109
+ "intent": "create_product",
110
+ "data": {{
111
+ "name": "Product name",
112
+ "price": number,
113
+ "unit": "Kg, Unit, etc.",
114
+ "description": "...",
115
+ "category": "vente" or "depense" or "stock",
116
+ "stock_status": "ok" or "low" or "rupture"
117
+ }}
118
+ }}
119
+ """
120
+
121
+ try:
122
+ chat_completion = self.client.chat.completions.create(
123
+ messages=[
124
+ {"role": "system", "content": system_prompt},
125
+ {"role": "user", "content": text}
126
+ ],
127
+ model=model,
128
+ response_format={"type": "json_object"},
129
+ temperature=0.0
130
+ )
131
+
132
+ result_text = chat_completion.choices[0].message.content
133
+ return json.loads(result_text)
134
+ except Exception as e:
135
+ print(f"Error calling Groq LLM ({model}): {e}")
136
+ return None
api/management/__init__.py ADDED
File without changes
api/management/commands/__init__.py ADDED
File without changes
api/management/commands/seed_data.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Commande Django pour créer des données de test
3
+ Usage: python manage.py seed_data
4
+ """
5
+
6
+ from django.core.management.base import BaseCommand
7
+ from django.contrib.auth import get_user_model
8
+ from django.utils import timezone
9
+ from decimal import Decimal
10
+ from datetime import timedelta
11
+ import random
12
+
13
+ from api.models import Product, Transaction, Budget, Ad
14
+
15
+ User = get_user_model()
16
+
17
+
18
+ class Command(BaseCommand):
19
+ help = 'Crée des données de test pour Akompta AI'
20
+
21
+ def add_arguments(self, parser):
22
+ parser.add_argument(
23
+ '--clear',
24
+ action='store_true',
25
+ help='Supprimer toutes les données existantes avant de créer de nouvelles données',
26
+ )
27
+
28
+ def handle(self, *args, **options):
29
+ if options['clear']:
30
+ self.stdout.write(self.style.WARNING('Suppression des données existantes...'))
31
+ Product.objects.all().delete()
32
+ Transaction.objects.all().delete()
33
+ Budget.objects.all().delete()
34
+ Ad.objects.all().delete()
35
+ User.objects.filter(is_superuser=False).delete()
36
+ self.stdout.write(self.style.SUCCESS('✓ Données supprimées'))
37
+
38
+ # Créer des utilisateurs de test
39
+ self.stdout.write('Création des utilisateurs...')
40
+
41
+ # Utilisateur personnel
42
+ personal_user, created = User.objects.get_or_create(
43
+ email='demo@akompta.com',
44
+ defaults={
45
+ 'first_name': 'Demo',
46
+ 'last_name': 'User',
47
+ 'account_type': 'personal',
48
+ 'phone_number': '+22890123456',
49
+ }
50
+ )
51
+ if created:
52
+ personal_user.set_password('demo123')
53
+ personal_user.save()
54
+ self.stdout.write(self.style.SUCCESS(f'✓ Utilisateur personnel créé: {personal_user.email}'))
55
+
56
+ # Utilisateur business
57
+ business_user, created = User.objects.get_or_create(
58
+ email='business@akompta.com',
59
+ defaults={
60
+ 'first_name': 'Business',
61
+ 'last_name': 'Owner',
62
+ 'account_type': 'business',
63
+ 'business_name': 'AgriTech Solutions',
64
+ 'sector': 'Agriculture',
65
+ 'location': 'Lomé, Togo',
66
+ 'ifu': '1234567890123',
67
+ 'phone_number': '+22890987654',
68
+ }
69
+ )
70
+ if created:
71
+ business_user.set_password('business123')
72
+ business_user.business_agreed = True
73
+ business_user.business_agreed_at = timezone.now()
74
+ business_user.save()
75
+ self.stdout.write(self.style.SUCCESS(f'✓ Utilisateur business créé: {business_user.email}'))
76
+
77
+ # Créer des produits
78
+ self.stdout.write('Création des produits...')
79
+ products_data = [
80
+ {'name': 'Tomates', 'price': '800', 'unit': 'Kg', 'category': 'vente', 'stock_status': 'ok'},
81
+ {'name': 'Oignons', 'price': '600', 'unit': 'Kg', 'category': 'vente', 'stock_status': 'low'},
82
+ {'name': 'Riz', 'price': '450', 'unit': 'Kg', 'category': 'stock', 'stock_status': 'ok'},
83
+ {'name': 'Huile', 'price': '2500', 'unit': 'Litre', 'category': 'stock', 'stock_status': 'rupture'},
84
+ {'name': 'Maïs', 'price': '350', 'unit': 'Kg', 'category': 'vente', 'stock_status': 'ok'},
85
+ ]
86
+
87
+ for user in [personal_user, business_user]:
88
+ for prod_data in products_data:
89
+ Product.objects.get_or_create(
90
+ user=user,
91
+ name=prod_data['name'],
92
+ defaults={
93
+ 'description': f'{prod_data["name"]} de qualité premium',
94
+ 'price': Decimal(prod_data['price']),
95
+ 'unit': prod_data['unit'],
96
+ 'category': prod_data['category'],
97
+ 'stock_status': prod_data['stock_status'],
98
+ }
99
+ )
100
+
101
+ self.stdout.write(self.style.SUCCESS(f'✓ {len(products_data) * 2} produits créés'))
102
+
103
+ # Créer des transactions
104
+ self.stdout.write('Création des transactions...')
105
+
106
+ categories_income = ['Ventes', 'Services', 'Consultation']
107
+ categories_expense = ['Transport', 'Loyer', 'Achats', 'Marketing', 'Salaires']
108
+
109
+ now = timezone.now()
110
+ transaction_count = 0
111
+
112
+ for user in [personal_user, business_user]:
113
+ # Transactions des 30 derniers jours
114
+ for i in range(50):
115
+ days_ago = random.randint(0, 30)
116
+ trans_date = now - timedelta(days=days_ago)
117
+
118
+ trans_type = random.choice(['income', 'expense'])
119
+
120
+ if trans_type == 'income':
121
+ category = random.choice(categories_income)
122
+ amount = Decimal(random.randint(5000, 50000))
123
+ name = f'Vente {category}'
124
+ else:
125
+ category = random.choice(categories_expense)
126
+ amount = Decimal(random.randint(1000, 30000))
127
+ name = f'Dépense {category}'
128
+
129
+ Transaction.objects.create(
130
+ user=user,
131
+ name=name,
132
+ amount=amount,
133
+ type=trans_type,
134
+ category=category,
135
+ date=trans_date,
136
+ currency='FCFA'
137
+ )
138
+ transaction_count += 1
139
+
140
+ self.stdout.write(self.style.SUCCESS(f'✓ {transaction_count} transactions créées'))
141
+
142
+ # Créer des budgets
143
+ self.stdout.write('Création des budgets...')
144
+
145
+ budgets_data = [
146
+ {'category': 'Transport', 'limit': '50000', 'color': '#3B82F6'},
147
+ {'category': 'Marketing', 'limit': '100000', 'color': '#EF4444'},
148
+ {'category': 'Achats', 'limit': '200000', 'color': '#10B981'},
149
+ ]
150
+
151
+ budget_count = 0
152
+ for user in [personal_user, business_user]:
153
+ for budget_data in budgets_data:
154
+ Budget.objects.get_or_create(
155
+ user=user,
156
+ category=budget_data['category'],
157
+ defaults={
158
+ 'limit': Decimal(budget_data['limit']),
159
+ 'color': budget_data['color'],
160
+ }
161
+ )
162
+ budget_count += 1
163
+
164
+ self.stdout.write(self.style.SUCCESS(f'✓ {budget_count} budgets créés'))
165
+
166
+ # Créer des annonces
167
+ self.stdout.write('Création des annonces...')
168
+
169
+ ads_data = [
170
+ {
171
+ 'product_name': 'Engrais Bio Premium',
172
+ 'owner_name': 'FertiTogo',
173
+ 'description': 'Engrais biologique de haute qualité pour toutes cultures. Augmentez vos rendements naturellement.',
174
+ 'whatsapp': '+22890111222',
175
+ 'location': 'Lomé, Togo',
176
+ 'is_verified': True,
177
+ },
178
+ {
179
+ 'product_name': 'Système d\'irrigation automatique',
180
+ 'owner_name': 'AgroTech Solutions',
181
+ 'description': 'Solutions d\'irrigation modernes pour optimiser votre consommation d\'eau.',
182
+ 'whatsapp': '+22890333444',
183
+ 'location': 'Kara, Togo',
184
+ 'is_verified': True,
185
+ },
186
+ {
187
+ 'product_name': 'Semences certifiées',
188
+ 'owner_name': 'SeedCorp Afrique',
189
+ 'description': 'Semences de maïs, riz et soja certifiées et adaptées au climat ouest-africain.',
190
+ 'whatsapp': '+22890555666',
191
+ 'location': 'Sokodé, Togo',
192
+ 'is_verified': True,
193
+ },
194
+ ]
195
+
196
+ for ad_data in ads_data:
197
+ Ad.objects.get_or_create(
198
+ user=business_user,
199
+ product_name=ad_data['product_name'],
200
+ defaults=ad_data
201
+ )
202
+
203
+ self.stdout.write(self.style.SUCCESS(f'✓ {len(ads_data)} annonces créées'))
204
+
205
+ # Résumé
206
+ self.stdout.write(self.style.SUCCESS('\n' + '='*50))
207
+ self.stdout.write(self.style.SUCCESS('DONNÉES DE TEST CRÉÉES AVEC SUCCÈS'))
208
+ self.stdout.write(self.style.SUCCESS('='*50))
209
+ self.stdout.write(self.style.SUCCESS('\nComptes de test :'))
210
+ self.stdout.write(f' • Personnel: demo@akompta.com / demo123')
211
+ self.stdout.write(f' • Business: business@akompta.com / business123')
212
+ self.stdout.write(self.style.SUCCESS('\nVous pouvez maintenant tester l\'API !'))
213
+
api/migrations/0001_initial.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 5.2.8 on 2025-11-28 00:35
2
+
3
+ import django.contrib.auth.models
4
+ import django.db.models.deletion
5
+ import django.utils.timezone
6
+ from django.conf import settings
7
+ from django.db import migrations, models
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ('auth', '0012_alter_user_first_name_max_length'),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.CreateModel(
20
+ name='User',
21
+ fields=[
22
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23
+ ('password', models.CharField(max_length=128, verbose_name='password')),
24
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
25
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
26
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
27
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
28
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
29
+ ('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')),
30
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
31
+ ('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
32
+ ('phone_number', models.CharField(blank=True, max_length=20)),
33
+ ('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
34
+ ('account_type', models.CharField(choices=[('personal', 'Personnel'), ('business', 'Professionnel')], default='personal', max_length=10)),
35
+ ('is_premium', models.BooleanField(default=False)),
36
+ ('business_name', models.CharField(blank=True, max_length=255)),
37
+ ('sector', models.CharField(blank=True, max_length=100)),
38
+ ('location', models.CharField(blank=True, max_length=255)),
39
+ ('ifu', models.CharField(blank=True, max_length=50, verbose_name='Identifiant Fiscal Unique')),
40
+ ('business_logo', models.ImageField(blank=True, null=True, upload_to='business_logos/')),
41
+ ('currency', models.CharField(default='XOF', max_length=10)),
42
+ ('language', models.CharField(default='FR', max_length=5)),
43
+ ('dark_mode', models.BooleanField(default=False)),
44
+ ('agreed_terms', models.BooleanField(default=False)),
45
+ ('business_agreed', models.BooleanField(default=False)),
46
+ ('business_agreed_at', models.DateTimeField(blank=True, null=True)),
47
+ ('created_at', models.DateTimeField(auto_now_add=True)),
48
+ ('updated_at', models.DateTimeField(auto_now=True)),
49
+ ('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')),
50
+ ('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')),
51
+ ],
52
+ options={
53
+ 'verbose_name': 'Utilisateur',
54
+ 'verbose_name_plural': 'Utilisateurs',
55
+ },
56
+ managers=[
57
+ ('objects', django.contrib.auth.models.UserManager()),
58
+ ],
59
+ ),
60
+ migrations.CreateModel(
61
+ name='Ad',
62
+ fields=[
63
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
64
+ ('product_name', models.CharField(max_length=255)),
65
+ ('owner_name', models.CharField(max_length=255)),
66
+ ('description', models.TextField()),
67
+ ('image', models.ImageField(upload_to='ads/')),
68
+ ('whatsapp', models.CharField(max_length=20)),
69
+ ('website', models.URLField(blank=True, null=True)),
70
+ ('location', models.CharField(max_length=255)),
71
+ ('is_verified', models.BooleanField(default=False)),
72
+ ('created_at', models.DateTimeField(auto_now_add=True)),
73
+ ('updated_at', models.DateTimeField(auto_now=True)),
74
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ads', to=settings.AUTH_USER_MODEL)),
75
+ ],
76
+ options={
77
+ 'verbose_name': 'Annonce',
78
+ 'verbose_name_plural': 'Annonces',
79
+ 'ordering': ['-created_at'],
80
+ },
81
+ ),
82
+ migrations.CreateModel(
83
+ name='Product',
84
+ fields=[
85
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
86
+ ('name', models.CharField(max_length=255)),
87
+ ('description', models.TextField(blank=True)),
88
+ ('price', models.DecimalField(decimal_places=2, max_digits=15)),
89
+ ('unit', models.CharField(default='Unité', max_length=50)),
90
+ ('image', models.ImageField(blank=True, null=True, upload_to='products/')),
91
+ ('category', models.CharField(choices=[('vente', 'Vente'), ('depense', 'Dépense'), ('stock', 'Stock')], max_length=20)),
92
+ ('stock_status', models.CharField(choices=[('ok', 'OK'), ('low', 'Faible'), ('rupture', 'Rupture')], default='ok', max_length=10)),
93
+ ('created_at', models.DateTimeField(auto_now_add=True)),
94
+ ('updated_at', models.DateTimeField(auto_now=True)),
95
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to=settings.AUTH_USER_MODEL)),
96
+ ],
97
+ options={
98
+ 'verbose_name': 'Produit',
99
+ 'verbose_name_plural': 'Produits',
100
+ 'ordering': ['-created_at'],
101
+ },
102
+ ),
103
+ migrations.CreateModel(
104
+ name='Budget',
105
+ fields=[
106
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
107
+ ('category', models.CharField(max_length=100)),
108
+ ('limit', models.DecimalField(decimal_places=2, max_digits=15)),
109
+ ('color', models.CharField(default='#4F46E5', max_length=7)),
110
+ ('created_at', models.DateTimeField(auto_now_add=True)),
111
+ ('updated_at', models.DateTimeField(auto_now=True)),
112
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budgets', to=settings.AUTH_USER_MODEL)),
113
+ ],
114
+ options={
115
+ 'verbose_name': 'Budget',
116
+ 'verbose_name_plural': 'Budgets',
117
+ 'unique_together': {('user', 'category')},
118
+ },
119
+ ),
120
+ migrations.CreateModel(
121
+ name='Transaction',
122
+ fields=[
123
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
124
+ ('name', models.CharField(max_length=255)),
125
+ ('amount', models.DecimalField(decimal_places=2, max_digits=15)),
126
+ ('type', models.CharField(choices=[('income', 'Revenu'), ('expense', 'Dépense')], max_length=10)),
127
+ ('category', models.CharField(max_length=100)),
128
+ ('date', models.DateTimeField()),
129
+ ('currency', models.CharField(default='FCFA', max_length=10)),
130
+ ('created_at', models.DateTimeField(auto_now_add=True)),
131
+ ('updated_at', models.DateTimeField(auto_now=True)),
132
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to=settings.AUTH_USER_MODEL)),
133
+ ],
134
+ options={
135
+ 'verbose_name': 'Transaction',
136
+ 'verbose_name_plural': 'Transactions',
137
+ 'ordering': ['-date'],
138
+ 'indexes': [models.Index(fields=['user', 'type'], name='api_transac_user_id_687bb9_idx'), models.Index(fields=['user', 'date'], name='api_transac_user_id_aacb53_idx'), models.Index(fields=['user', 'category'], name='api_transac_user_id_a594d0_idx')],
139
+ },
140
+ ),
141
+ ]
api/migrations/0002_alter_user_managers.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 5.2.8 on 2025-11-28 00:40
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('api', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterModelManagers(
14
+ name='user',
15
+ managers=[
16
+ ],
17
+ ),
18
+ ]
api/migrations/0003_notification_supportticket.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 5.2.8 on 2025-11-28 08:49
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ('api', '0002_alter_user_managers'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='Notification',
17
+ fields=[
18
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+ ('type', models.CharField(choices=[('reminder', 'Rappel'), ('profit', 'Profit'), ('promo', 'Promo'), ('system', 'Système')], default='system', max_length=20)),
20
+ ('title', models.CharField(max_length=255)),
21
+ ('message', models.TextField()),
22
+ ('is_read', models.BooleanField(default=False)),
23
+ ('created_at', models.DateTimeField(auto_now_add=True)),
24
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
25
+ ],
26
+ options={
27
+ 'verbose_name': 'Notification',
28
+ 'verbose_name_plural': 'Notifications',
29
+ 'ordering': ['-created_at'],
30
+ },
31
+ ),
32
+ migrations.CreateModel(
33
+ name='SupportTicket',
34
+ fields=[
35
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
36
+ ('subject', models.CharField(max_length=255)),
37
+ ('message', models.TextField()),
38
+ ('status', models.CharField(choices=[('open', 'Ouvert'), ('in_progress', 'En cours'), ('closed', 'Fermé')], default='open', max_length=20)),
39
+ ('created_at', models.DateTimeField(auto_now_add=True)),
40
+ ('updated_at', models.DateTimeField(auto_now=True)),
41
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='support_tickets', to=settings.AUTH_USER_MODEL)),
42
+ ],
43
+ options={
44
+ 'verbose_name': 'Ticket Support',
45
+ 'verbose_name_plural': 'Tickets Support',
46
+ 'ordering': ['-created_at'],
47
+ },
48
+ ),
49
+ ]
api/migrations/0004_aiinsight.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 5.2.8 on 2026-01-22 20:50
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ('api', '0003_notification_supportticket'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='AIInsight',
17
+ fields=[
18
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+ ('content', models.JSONField()),
20
+ ('context_hash', models.CharField(max_length=64)),
21
+ ('created_at', models.DateTimeField(auto_now_add=True)),
22
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_insights', to=settings.AUTH_USER_MODEL)),
23
+ ],
24
+ options={
25
+ 'verbose_name': 'Insight IA',
26
+ 'verbose_name_plural': 'Insights IA',
27
+ 'ordering': ['-created_at'],
28
+ },
29
+ ),
30
+ ]
api/migrations/0005_user_initial_balance.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 5.2.8 on 2026-01-22 21:34
2
+
3
+ from decimal import Decimal
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('api', '0004_aiinsight'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name='user',
16
+ name='initial_balance',
17
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=15),
18
+ ),
19
+ ]
api/migrations/__init__.py ADDED
File without changes
api/models.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.db import models
2
+ from django.contrib.auth.models import AbstractUser, BaseUserManager
3
+ from django.core.validators import RegexValidator
4
+ from decimal import Decimal
5
+
6
+
7
+ class UserManager(BaseUserManager):
8
+ """Custom user manager for email-based authentication"""
9
+
10
+ def create_user(self, email, password=None, **extra_fields):
11
+ """Create and save a regular user with the given email and password"""
12
+ if not email:
13
+ raise ValueError('L\'adresse email est obligatoire')
14
+
15
+ email = self.normalize_email(email)
16
+ user = self.model(email=email, **extra_fields)
17
+ user.set_password(password)
18
+ user.save(using=self._db)
19
+ return user
20
+
21
+ def create_superuser(self, email, password=None, **extra_fields):
22
+ """Create and save a superuser with the given email and password"""
23
+ extra_fields.setdefault('is_staff', True)
24
+ extra_fields.setdefault('is_superuser', True)
25
+ extra_fields.setdefault('is_active', True)
26
+
27
+ if extra_fields.get('is_staff') is not True:
28
+ raise ValueError('Le superutilisateur doit avoir is_staff=True.')
29
+ if extra_fields.get('is_superuser') is not True:
30
+ raise ValueError('Le superutilisateur doit avoir is_superuser=True.')
31
+
32
+ return self.create_user(email, password, **extra_fields)
33
+
34
+
35
+ class User(AbstractUser):
36
+ """Modèle utilisateur étendu pour Akompta"""
37
+
38
+ ACCOUNT_TYPE_CHOICES = [
39
+ ('personal', 'Personnel'),
40
+ ('business', 'Professionnel'),
41
+ ]
42
+
43
+ # Utiliser email comme identifiant
44
+ username = None
45
+ email = models.EmailField(unique=True, verbose_name="Email")
46
+
47
+ # Champs communs
48
+ phone_number = models.CharField(max_length=20, blank=True)
49
+ avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
50
+ account_type = models.CharField(
51
+ max_length=10,
52
+ choices=ACCOUNT_TYPE_CHOICES,
53
+ default='personal'
54
+ )
55
+ is_premium = models.BooleanField(default=False)
56
+ initial_balance = models.DecimalField(max_digits=15, decimal_places=2, default=Decimal('0.00'))
57
+
58
+ # Champs Business
59
+ business_name = models.CharField(max_length=255, blank=True)
60
+ sector = models.CharField(max_length=100, blank=True)
61
+ location = models.CharField(max_length=255, blank=True)
62
+ ifu = models.CharField(
63
+ max_length=50,
64
+ blank=True,
65
+ verbose_name="Identifiant Fiscal Unique"
66
+ )
67
+ business_logo = models.ImageField(upload_to='business_logos/', blank=True, null=True)
68
+
69
+ # Settings (Stockés en JSON)
70
+ currency = models.CharField(max_length=10, default='XOF')
71
+ language = models.CharField(max_length=5, default='FR')
72
+ dark_mode = models.BooleanField(default=False)
73
+
74
+ # Acceptation des conditions
75
+ agreed_terms = models.BooleanField(default=False)
76
+ business_agreed = models.BooleanField(default=False)
77
+ business_agreed_at = models.DateTimeField(blank=True, null=True)
78
+
79
+ # Timestamps
80
+ created_at = models.DateTimeField(auto_now_add=True)
81
+ updated_at = models.DateTimeField(auto_now=True)
82
+
83
+ # Custom manager
84
+ objects = UserManager()
85
+
86
+ USERNAME_FIELD = 'email'
87
+ REQUIRED_FIELDS = ['first_name', 'last_name']
88
+
89
+ class Meta:
90
+ verbose_name = "Utilisateur"
91
+ verbose_name_plural = "Utilisateurs"
92
+
93
+ def __str__(self):
94
+ return self.email
95
+
96
+ def save(self, *args, **kwargs):
97
+ # Validation IFU pour les comptes business
98
+ if self.account_type == 'business' and not self.ifu:
99
+ from django.core.exceptions import ValidationError
100
+ raise ValidationError(
101
+ "Le champ IFU est obligatoire pour les comptes professionnels."
102
+ )
103
+ super().save(*args, **kwargs)
104
+
105
+
106
+ class Product(models.Model):
107
+ """Modèle pour les produits/inventaire"""
108
+
109
+ CATEGORY_CHOICES = [
110
+ ('vente', 'Vente'),
111
+ ('depense', 'Dépense'),
112
+ ('stock', 'Stock'),
113
+ ]
114
+
115
+ STOCK_STATUS_CHOICES = [
116
+ ('ok', 'OK'),
117
+ ('low', 'Faible'),
118
+ ('rupture', 'Rupture'),
119
+ ]
120
+
121
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='products')
122
+ name = models.CharField(max_length=255)
123
+ description = models.TextField(blank=True)
124
+ price = models.DecimalField(max_digits=15, decimal_places=2)
125
+ unit = models.CharField(max_length=50, default='Unité')
126
+ image = models.ImageField(upload_to='products/', blank=True, null=True)
127
+ category = models.CharField(max_length=20, choices=CATEGORY_CHOICES)
128
+ stock_status = models.CharField(
129
+ max_length=10,
130
+ choices=STOCK_STATUS_CHOICES,
131
+ default='ok'
132
+ )
133
+ created_at = models.DateTimeField(auto_now_add=True)
134
+ updated_at = models.DateTimeField(auto_now=True)
135
+
136
+ class Meta:
137
+ verbose_name = "Produit"
138
+ verbose_name_plural = "Produits"
139
+ ordering = ['-created_at']
140
+
141
+ def __str__(self):
142
+ return f"{self.name} - {self.user.email}"
143
+
144
+
145
+ class Transaction(models.Model):
146
+ """Modèle pour les transactions financières"""
147
+
148
+ TYPE_CHOICES = [
149
+ ('income', 'Revenu'),
150
+ ('expense', 'Dépense'),
151
+ ]
152
+
153
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='transactions')
154
+ name = models.CharField(max_length=255)
155
+ amount = models.DecimalField(max_digits=15, decimal_places=2)
156
+ type = models.CharField(max_length=10, choices=TYPE_CHOICES)
157
+ category = models.CharField(max_length=100)
158
+ date = models.DateTimeField()
159
+ currency = models.CharField(max_length=10, default='FCFA')
160
+
161
+ # Support pour la synchro hors-ligne
162
+ created_at = models.DateTimeField(auto_now_add=True)
163
+ updated_at = models.DateTimeField(auto_now=True)
164
+
165
+ class Meta:
166
+ verbose_name = "Transaction"
167
+ verbose_name_plural = "Transactions"
168
+ ordering = ['-date']
169
+ indexes = [
170
+ models.Index(fields=['user', 'type']),
171
+ models.Index(fields=['user', 'date']),
172
+ models.Index(fields=['user', 'category']),
173
+ ]
174
+
175
+ def __str__(self):
176
+ return f"{self.name} - {self.amount} {self.currency}"
177
+
178
+
179
+ class Budget(models.Model):
180
+ """Modèle pour les budgets suivis"""
181
+
182
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='budgets')
183
+ category = models.CharField(max_length=100)
184
+ limit = models.DecimalField(max_digits=15, decimal_places=2)
185
+ color = models.CharField(max_length=7, default='#4F46E5') # Hex color
186
+
187
+ created_at = models.DateTimeField(auto_now_add=True)
188
+ updated_at = models.DateTimeField(auto_now=True)
189
+
190
+ class Meta:
191
+ verbose_name = "Budget"
192
+ verbose_name_plural = "Budgets"
193
+ unique_together = ['user', 'category']
194
+
195
+ def __str__(self):
196
+ return f"{self.category} - {self.limit}"
197
+
198
+ def get_spent_amount(self):
199
+ """Calcule le montant dépensé pour cette catégorie"""
200
+ from django.db.models import Sum
201
+ result = self.user.transactions.filter(
202
+ type='expense',
203
+ category=self.category
204
+ ).aggregate(total=Sum('amount'))
205
+ return result['total'] or Decimal('0.00')
206
+
207
+
208
+ class Ad(models.Model):
209
+ """Modèle pour les annonces partenaires"""
210
+
211
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ads')
212
+ product_name = models.CharField(max_length=255)
213
+ owner_name = models.CharField(max_length=255)
214
+ description = models.TextField()
215
+ image = models.ImageField(upload_to='ads/')
216
+ whatsapp = models.CharField(max_length=20)
217
+ website = models.URLField(blank=True, null=True)
218
+ location = models.CharField(max_length=255)
219
+ is_verified = models.BooleanField(default=False)
220
+
221
+ created_at = models.DateTimeField(auto_now_add=True)
222
+ updated_at = models.DateTimeField(auto_now=True)
223
+
224
+ class Meta:
225
+ verbose_name = "Annonce"
226
+ verbose_name_plural = "Annonces"
227
+ ordering = ['-created_at']
228
+
229
+ def __str__(self):
230
+ return f"{self.product_name} - {self.owner_name}"
231
+
232
+
233
+ class Notification(models.Model):
234
+ """Modèle pour les notifications"""
235
+
236
+ TYPE_CHOICES = [
237
+ ('reminder', 'Rappel'),
238
+ ('profit', 'Profit'),
239
+ ('promo', 'Promo'),
240
+ ('system', 'Système'),
241
+ ]
242
+
243
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications')
244
+ type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='system')
245
+ title = models.CharField(max_length=255)
246
+ message = models.TextField()
247
+ is_read = models.BooleanField(default=False)
248
+ created_at = models.DateTimeField(auto_now_add=True)
249
+
250
+ class Meta:
251
+ verbose_name = "Notification"
252
+ verbose_name_plural = "Notifications"
253
+ ordering = ['-created_at']
254
+
255
+ def __str__(self):
256
+ return f"{self.title} - {self.user.email}"
257
+
258
+
259
+ class SupportTicket(models.Model):
260
+ """Modèle pour le support client"""
261
+
262
+ STATUS_CHOICES = [
263
+ ('open', 'Ouvert'),
264
+ ('in_progress', 'En cours'),
265
+ ('closed', 'Fermé'),
266
+ ]
267
+
268
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='support_tickets')
269
+ subject = models.CharField(max_length=255)
270
+ message = models.TextField()
271
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
272
+ created_at = models.DateTimeField(auto_now_add=True)
273
+ updated_at = models.DateTimeField(auto_now=True)
274
+
275
+ class Meta:
276
+ verbose_name = "Ticket Support"
277
+ verbose_name_plural = "Tickets Support"
278
+ ordering = ['-created_at']
279
+
280
+ def __str__(self):
281
+ return f"{self.subject} - {self.status}"
282
+
283
+
284
+ class AIInsight(models.Model):
285
+ """Modèle pour stocker les insights générés par l'IA"""
286
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ai_insights')
287
+ content = models.JSONField() # Liste de phrases
288
+ context_hash = models.CharField(max_length=64) # Hash des données utilisées pour la génération
289
+ created_at = models.DateTimeField(auto_now_add=True)
290
+
291
+ class Meta:
292
+ verbose_name = "Insight IA"
293
+ verbose_name_plural = "Insights IA"
294
+ ordering = ['-created_at']
295
+
296
+ def __str__(self):
297
+ return f"Insight pour {self.user.email} - {self.created_at}"
298
+
api/serializers.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from rest_framework import serializers
2
+ from django.contrib.auth import get_user_model
3
+ from django.contrib.auth.password_validation import validate_password
4
+ from django.utils import timezone
5
+ from .models import Product, Transaction, Budget, Ad, Notification, SupportTicket
6
+
7
+ User = get_user_model()
8
+
9
+
10
+ class UserSerializer(serializers.ModelSerializer):
11
+ """Serializer pour le profil utilisateur"""
12
+
13
+ class Meta:
14
+ model = User
15
+ fields = [
16
+ 'id', 'email', 'first_name', 'last_name', 'phone_number',
17
+ 'avatar', 'account_type', 'is_premium', 'initial_balance',
18
+ 'business_name', 'sector', 'location', 'ifu', 'business_logo',
19
+ 'currency', 'language', 'dark_mode',
20
+ 'created_at', 'updated_at'
21
+ ]
22
+ read_only_fields = ['id', 'created_at', 'updated_at']
23
+
24
+
25
+ class RegisterSerializer(serializers.ModelSerializer):
26
+ """Serializer pour l'inscription"""
27
+
28
+ password = serializers.CharField(
29
+ write_only=True,
30
+ required=True,
31
+ validators=[validate_password]
32
+ )
33
+ password2 = serializers.CharField(write_only=True, required=True)
34
+ agreed = serializers.BooleanField(write_only=True, required=True)
35
+ businessAgreed = serializers.BooleanField(write_only=True, required=False)
36
+
37
+ class Meta:
38
+ model = User
39
+ fields = [
40
+ 'email', 'password', 'password2', 'first_name', 'last_name',
41
+ 'phone_number', 'account_type', 'business_name', 'sector',
42
+ 'location', 'ifu', 'agreed', 'businessAgreed'
43
+ ]
44
+
45
+ def validate(self, attrs):
46
+ if attrs['password'] != attrs['password2']:
47
+ raise serializers.ValidationError({
48
+ "password": "Les mots de passe ne correspondent pas."
49
+ })
50
+
51
+ # Validation IFU pour les comptes business
52
+ if attrs.get('account_type') == 'business':
53
+ if not attrs.get('ifu'):
54
+ raise serializers.ValidationError({
55
+ "ifu": "Ce champ est obligatoire pour les comptes professionnels."
56
+ })
57
+ if not attrs.get('businessAgreed'):
58
+ raise serializers.ValidationError({
59
+ "businessAgreed": "Vous devez accepter les conditions professionnelles."
60
+ })
61
+
62
+ if not attrs.get('agreed'):
63
+ raise serializers.ValidationError({
64
+ "agreed": "Vous devez accepter les conditions générales."
65
+ })
66
+
67
+ return attrs
68
+
69
+ def create(self, validated_data):
70
+ validated_data.pop('password2')
71
+ validated_data.pop('agreed')
72
+ business_agreed = validated_data.pop('businessAgreed', False)
73
+
74
+ password = validated_data.pop('password')
75
+ user = User.objects.create_user(password=password, **validated_data)
76
+
77
+ user.agreed_terms = True
78
+ if business_agreed:
79
+ user.business_agreed = True
80
+ user.business_agreed_at = timezone.now()
81
+ user.save()
82
+
83
+ return user
84
+
85
+
86
+ class ChangePasswordSerializer(serializers.Serializer):
87
+ """Serializer pour le changement de mot de passe"""
88
+
89
+ old_password = serializers.CharField(required=True)
90
+ new_password = serializers.CharField(
91
+ required=True,
92
+ validators=[validate_password]
93
+ )
94
+ new_password2 = serializers.CharField(required=True)
95
+
96
+ def validate(self, attrs):
97
+ if attrs['new_password'] != attrs['new_password2']:
98
+ raise serializers.ValidationError({
99
+ "new_password": "Les mots de passe ne correspondent pas."
100
+ })
101
+ return attrs
102
+
103
+
104
+ class ProductSerializer(serializers.ModelSerializer):
105
+ """Serializer pour les produits"""
106
+
107
+ class Meta:
108
+ model = Product
109
+ fields = [
110
+ 'id', 'name', 'description', 'price', 'unit', 'image',
111
+ 'category', 'stock_status', 'created_at', 'updated_at'
112
+ ]
113
+ read_only_fields = ['id', 'created_at', 'updated_at']
114
+
115
+ def create(self, validated_data):
116
+ validated_data['user'] = self.context['request'].user
117
+ return super().create(validated_data)
118
+
119
+
120
+ class TransactionSerializer(serializers.ModelSerializer):
121
+ """Serializer pour les transactions"""
122
+
123
+ class Meta:
124
+ model = Transaction
125
+ fields = [
126
+ 'id', 'name', 'amount', 'type', 'category', 'date',
127
+ 'currency', 'created_at', 'updated_at'
128
+ ]
129
+ read_only_fields = ['id', 'created_at', 'updated_at']
130
+
131
+ def create(self, validated_data):
132
+ validated_data['user'] = self.context['request'].user
133
+ return super().create(validated_data)
134
+
135
+
136
+ class TransactionSummarySerializer(serializers.Serializer):
137
+ """Serializer pour le résumé des transactions (dashboard)"""
138
+
139
+ balance = serializers.DecimalField(max_digits=15, decimal_places=2)
140
+ income_24h = serializers.DecimalField(max_digits=15, decimal_places=2)
141
+ expenses_24h = serializers.DecimalField(max_digits=15, decimal_places=2)
142
+ income_variation = serializers.FloatField()
143
+ expenses_variation = serializers.FloatField()
144
+
145
+
146
+ class BudgetSerializer(serializers.ModelSerializer):
147
+ """Serializer pour les budgets"""
148
+
149
+ spent_amount = serializers.SerializerMethodField()
150
+ percentage = serializers.SerializerMethodField()
151
+
152
+ class Meta:
153
+ model = Budget
154
+ fields = [
155
+ 'id', 'category', 'limit', 'color', 'spent_amount',
156
+ 'percentage', 'created_at', 'updated_at'
157
+ ]
158
+ read_only_fields = ['id', 'spent_amount', 'percentage', 'created_at', 'updated_at']
159
+
160
+ def get_spent_amount(self, obj):
161
+ return obj.get_spent_amount()
162
+
163
+ def get_percentage(self, obj):
164
+ spent = obj.get_spent_amount()
165
+ if obj.limit > 0:
166
+ return float((spent / obj.limit) * 100)
167
+ return 0.0
168
+
169
+ def create(self, validated_data):
170
+ validated_data['user'] = self.context['request'].user
171
+ return super().create(validated_data)
172
+
173
+
174
+ class AdSerializer(serializers.ModelSerializer):
175
+ """Serializer pour les annonces"""
176
+
177
+ class Meta:
178
+ model = Ad
179
+ fields = [
180
+ 'id', 'product_name', 'owner_name', 'description', 'image',
181
+ 'whatsapp', 'website', 'location', 'is_verified',
182
+ 'created_at', 'updated_at'
183
+ ]
184
+ read_only_fields = ['id', 'is_verified', 'created_at', 'updated_at']
185
+
186
+ def create(self, validated_data):
187
+ validated_data['user'] = self.context['request'].user
188
+ return super().create(validated_data)
189
+
190
+
191
+ class OverviewAnalyticsSerializer(serializers.Serializer):
192
+ """Serializer pour les analytics overview (graphique barres)"""
193
+
194
+ month = serializers.CharField()
195
+ income = serializers.DecimalField(max_digits=15, decimal_places=2)
196
+ expenses = serializers.DecimalField(max_digits=15, decimal_places=2)
197
+
198
+
199
+ class BreakdownAnalyticsSerializer(serializers.Serializer):
200
+ """Serializer pour le breakdown des dépenses (camembert)"""
201
+
202
+ category = serializers.CharField()
203
+ amount = serializers.DecimalField(max_digits=15, decimal_places=2)
204
+ percentage = serializers.FloatField()
205
+
206
+
207
+ class KPISerializer(serializers.Serializer):
208
+ """Serializer pour les KPIs"""
209
+
210
+ average_basket = serializers.DecimalField(max_digits=15, decimal_places=2)
211
+ average_basket_growth = serializers.FloatField(default=0.0)
212
+ estimated_mrr = serializers.DecimalField(max_digits=15, decimal_places=2)
213
+ estimated_mrr_growth = serializers.FloatField(default=0.0)
214
+ cac = serializers.DecimalField(max_digits=15, decimal_places=2)
215
+ cac_growth = serializers.FloatField(default=0.0)
216
+
217
+
218
+ class ActivityAnalyticsSerializer(serializers.Serializer):
219
+ """Serializer pour l'activité hebdomadaire"""
220
+ day = serializers.CharField()
221
+ sales = serializers.DecimalField(max_digits=15, decimal_places=2)
222
+
223
+
224
+ class BalanceHistorySerializer(serializers.Serializer):
225
+ """Serializer pour l'historique du solde"""
226
+ date = serializers.CharField()
227
+ balance = serializers.DecimalField(max_digits=15, decimal_places=2)
228
+
229
+
230
+ class NotificationSerializer(serializers.ModelSerializer):
231
+ """Serializer pour les notifications"""
232
+
233
+ class Meta:
234
+ model = Notification
235
+ fields = ['id', 'type', 'title', 'message', 'is_read', 'created_at']
236
+ read_only_fields = ['id', 'created_at']
237
+
238
+
239
+ class SupportTicketSerializer(serializers.ModelSerializer):
240
+ """Serializer pour les tickets support"""
241
+
242
+ class Meta:
243
+ model = SupportTicket
244
+ fields = ['id', 'subject', 'message', 'status', 'created_at', 'updated_at']
245
+ read_only_fields = ['id', 'status', 'created_at', 'updated_at']
246
+
247
+ def create(self, validated_data):
248
+ validated_data['user'] = self.context['request'].user
249
+ return super().create(validated_data)
api/tests.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.test import TestCase
2
+ from django.urls import reverse
3
+ from rest_framework.test import APITestCase, APIClient
4
+ from rest_framework import status
5
+ from django.contrib.auth import get_user_model
6
+ from decimal import Decimal
7
+ from django.utils import timezone
8
+
9
+ from .models import Product, Transaction, Budget, Ad
10
+
11
+ User = get_user_model()
12
+
13
+
14
+ class AuthenticationTests(APITestCase):
15
+ """Tests pour l'authentification"""
16
+
17
+ def setUp(self):
18
+ self.client = APIClient()
19
+ self.register_url = reverse('register')
20
+ self.login_url = reverse('login')
21
+
22
+ def test_register_personal_account(self):
23
+ """Test inscription compte personnel"""
24
+ data = {
25
+ 'email': 'test@example.com',
26
+ 'password': 'TestPass123!',
27
+ 'password2': 'TestPass123!',
28
+ 'first_name': 'John',
29
+ 'last_name': 'Doe',
30
+ 'account_type': 'personal',
31
+ 'agreed': True
32
+ }
33
+ response = self.client.post(self.register_url, data)
34
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
35
+ self.assertIn('tokens', response.data)
36
+ self.assertIn('user', response.data)
37
+
38
+ def test_register_business_without_ifu_fails(self):
39
+ """Test que l'inscription business sans IFU échoue"""
40
+ data = {
41
+ 'email': 'business@example.com',
42
+ 'password': 'TestPass123!',
43
+ 'password2': 'TestPass123!',
44
+ 'first_name': 'Jane',
45
+ 'last_name': 'Smith',
46
+ 'account_type': 'business',
47
+ 'business_name': 'Test Corp',
48
+ 'agreed': True,
49
+ 'businessAgreed': True
50
+ }
51
+ response = self.client.post(self.register_url, data)
52
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
53
+ self.assertIn('ifu', response.data['errors'])
54
+
55
+ def test_register_business_with_ifu_succeeds(self):
56
+ """Test inscription business avec IFU réussit"""
57
+ data = {
58
+ 'email': 'business@example.com',
59
+ 'password': 'TestPass123!',
60
+ 'password2': 'TestPass123!',
61
+ 'first_name': 'Jane',
62
+ 'last_name': 'Smith',
63
+ 'account_type': 'business',
64
+ 'business_name': 'Test Corp',
65
+ 'ifu': '123456789',
66
+ 'agreed': True,
67
+ 'businessAgreed': True
68
+ }
69
+ response = self.client.post(self.register_url, data)
70
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
71
+
72
+ def test_login_with_email(self):
73
+ """Test connexion avec email"""
74
+ # Créer un utilisateur
75
+ user = User.objects.create_user(
76
+ email='test@example.com',
77
+ password='TestPass123!',
78
+ first_name='John',
79
+ last_name='Doe'
80
+ )
81
+
82
+ # Tenter la connexion
83
+ data = {
84
+ 'email': 'test@example.com',
85
+ 'password': 'TestPass123!'
86
+ }
87
+ response = self.client.post(self.login_url, data)
88
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
89
+ self.assertIn('tokens', response.data)
90
+
91
+
92
+ class ProductTests(APITestCase):
93
+ """Tests pour les produits"""
94
+
95
+ def setUp(self):
96
+ self.user = User.objects.create_user(
97
+ email='test@example.com',
98
+ password='TestPass123!',
99
+ first_name='John',
100
+ last_name='Doe'
101
+ )
102
+ self.client = APIClient()
103
+ self.client.force_authenticate(user=self.user)
104
+ self.products_url = reverse('product-list')
105
+
106
+ def test_create_product(self):
107
+ """Test création de produit"""
108
+ data = {
109
+ 'name': 'Tomates',
110
+ 'description': 'Tomates fraîches',
111
+ 'price': '500.00',
112
+ 'unit': 'Kg',
113
+ 'category': 'vente',
114
+ 'stock_status': 'ok'
115
+ }
116
+ response = self.client.post(self.products_url, data)
117
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
118
+ self.assertEqual(Product.objects.count(), 1)
119
+ self.assertEqual(Product.objects.first().user, self.user)
120
+
121
+ def test_list_products(self):
122
+ """Test récupération de la liste des produits"""
123
+ Product.objects.create(
124
+ user=self.user,
125
+ name='Tomates',
126
+ price=Decimal('500.00'),
127
+ category='vente'
128
+ )
129
+ response = self.client.get(self.products_url)
130
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
131
+ self.assertEqual(len(response.data['results']), 1)
132
+
133
+ def test_filter_products_by_category(self):
134
+ """Test filtrage des produits par catégorie"""
135
+ Product.objects.create(
136
+ user=self.user,
137
+ name='Tomates',
138
+ price=Decimal('500.00'),
139
+ category='vente'
140
+ )
141
+ Product.objects.create(
142
+ user=self.user,
143
+ name='Essence',
144
+ price=Decimal('1000.00'),
145
+ category='depense'
146
+ )
147
+
148
+ response = self.client.get(self.products_url + '?category=vente')
149
+ self.assertEqual(len(response.data['results']), 1)
150
+ self.assertEqual(response.data['results'][0]['name'], 'Tomates')
151
+
152
+
153
+ class TransactionTests(APITestCase):
154
+ """Tests pour les transactions"""
155
+
156
+ def setUp(self):
157
+ self.user = User.objects.create_user(
158
+ email='test@example.com',
159
+ password='TestPass123!',
160
+ first_name='John',
161
+ last_name='Doe'
162
+ )
163
+ self.client = APIClient()
164
+ self.client.force_authenticate(user=self.user)
165
+ self.transactions_url = reverse('transaction-list')
166
+
167
+ def test_create_transaction(self):
168
+ """Test création de transaction"""
169
+ data = {
170
+ 'name': 'Vente tomates',
171
+ 'amount': '5000.00',
172
+ 'type': 'income',
173
+ 'category': 'Ventes',
174
+ 'date': timezone.now().isoformat()
175
+ }
176
+ response = self.client.post(self.transactions_url, data)
177
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
178
+ self.assertEqual(Transaction.objects.count(), 1)
179
+
180
+ def test_transaction_summary(self):
181
+ """Test résumé des transactions"""
182
+ now = timezone.now()
183
+
184
+ # Créer des transactions
185
+ Transaction.objects.create(
186
+ user=self.user,
187
+ name='Vente',
188
+ amount=Decimal('10000.00'),
189
+ type='income',
190
+ category='Ventes',
191
+ date=now
192
+ )
193
+ Transaction.objects.create(
194
+ user=self.user,
195
+ name='Achat',
196
+ amount=Decimal('3000.00'),
197
+ type='expense',
198
+ category='Achats',
199
+ date=now
200
+ )
201
+
202
+ summary_url = reverse('transaction-summary')
203
+ response = self.client.get(summary_url)
204
+
205
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
206
+ self.assertEqual(Decimal(response.data['balance']), Decimal('7000.00'))
207
+
208
+
209
+ class BudgetTests(APITestCase):
210
+ """Tests pour les budgets"""
211
+
212
+ def setUp(self):
213
+ self.user = User.objects.create_user(
214
+ email='test@example.com',
215
+ password='TestPass123!',
216
+ first_name='John',
217
+ last_name='Doe'
218
+ )
219
+ self.client = APIClient()
220
+ self.client.force_authenticate(user=self.user)
221
+ self.budgets_url = reverse('budget-list')
222
+
223
+ def test_create_budget(self):
224
+ """Test création de budget"""
225
+ data = {
226
+ 'category': 'Transport',
227
+ 'limit': '50000.00',
228
+ 'color': '#FF5733'
229
+ }
230
+ response = self.client.post(self.budgets_url, data)
231
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
232
+ self.assertEqual(Budget.objects.count(), 1)
233
+
234
+ def test_budget_spent_amount_calculation(self):
235
+ """Test calcul du montant dépensé"""
236
+ # Créer un budget
237
+ budget = Budget.objects.create(
238
+ user=self.user,
239
+ category='Transport',
240
+ limit=Decimal('50000.00')
241
+ )
242
+
243
+ # Créer des dépenses dans cette catégorie
244
+ Transaction.objects.create(
245
+ user=self.user,
246
+ name='Taxi',
247
+ amount=Decimal('5000.00'),
248
+ type='expense',
249
+ category='Transport',
250
+ date=timezone.now()
251
+ )
252
+ Transaction.objects.create(
253
+ user=self.user,
254
+ name='Essence',
255
+ amount=Decimal('10000.00'),
256
+ type='expense',
257
+ category='Transport',
258
+ date=timezone.now()
259
+ )
260
+
261
+ # Vérifier le calcul
262
+ response = self.client.get(self.budgets_url)
263
+ budget_data = response.data['results'][0]
264
+
265
+ self.assertEqual(Decimal(budget_data['spent_amount']), Decimal('15000.00'))
266
+ self.assertEqual(budget_data['percentage'], 30.0)
267
+
268
+
269
+ class AdTests(APITestCase):
270
+ """Tests pour les annonces"""
271
+
272
+ def setUp(self):
273
+ self.user = User.objects.create_user(
274
+ email='test@example.com',
275
+ password='TestPass123!',
276
+ first_name='John',
277
+ last_name='Doe'
278
+ )
279
+ self.client = APIClient()
280
+ self.ads_url = reverse('ad-list')
281
+
282
+ def test_list_ads_without_auth(self):
283
+ """Test que les annonces sont accessibles sans authentification"""
284
+ Ad.objects.create(
285
+ user=self.user,
286
+ product_name='Engrais bio',
287
+ owner_name='AgriCorp',
288
+ description='Engrais de qualité',
289
+ whatsapp='+22890123456',
290
+ location='Lomé',
291
+ is_verified=True
292
+ )
293
+
294
+ response = self.client.get(self.ads_url)
295
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
296
+ self.assertEqual(len(response.data['results']), 1)
297
+
298
+ def test_create_ad_requires_auth(self):
299
+ """Test que la création d'annonce requiert l'authentification"""
300
+ data = {
301
+ 'product_name': 'Test Product',
302
+ 'owner_name': 'Test Owner',
303
+ 'description': 'Test description',
304
+ 'whatsapp': '+22890123456',
305
+ 'location': 'Lomé'
306
+ }
307
+ response = self.client.post(self.ads_url, data)
308
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
309
+
310
+
311
+ class ModelTests(TestCase):
312
+ """Tests pour les modèles"""
313
+
314
+ def test_user_creation_personal(self):
315
+ """Test création utilisateur personnel"""
316
+ user = User.objects.create_user(
317
+ email='test@example.com',
318
+ password='TestPass123!',
319
+ first_name='John',
320
+ last_name='Doe',
321
+ account_type='personal'
322
+ )
323
+ self.assertEqual(user.email, 'test@example.com')
324
+ self.assertEqual(user.account_type, 'personal')
325
+ self.assertFalse(user.is_premium)
326
+
327
+ def test_user_business_requires_ifu(self):
328
+ """Test que les comptes business nécessitent un IFU"""
329
+ from django.core.exceptions import ValidationError
330
+
331
+ user = User(
332
+ email='business@example.com',
333
+ account_type='business',
334
+ business_name='Test Corp',
335
+ first_name='Jane',
336
+ last_name='Smith'
337
+ )
338
+ user.set_password('TestPass123!')
339
+
340
+ with self.assertRaises(ValidationError):
341
+ user.save()
342
+
343
+ def test_budget_spent_amount_method(self):
344
+ """Test méthode get_spent_amount du Budget"""
345
+ user = User.objects.create_user(
346
+ email='test@example.com',
347
+ password='TestPass123!',
348
+ first_name='John',
349
+ last_name='Doe'
350
+ )
351
+
352
+ budget = Budget.objects.create(
353
+ user=user,
354
+ category='Transport',
355
+ limit=Decimal('50000.00')
356
+ )
357
+
358
+ Transaction.objects.create(
359
+ user=user,
360
+ name='Taxi',
361
+ amount=Decimal('5000.00'),
362
+ type='expense',
363
+ category='Transport',
364
+ date=timezone.now()
365
+ )
366
+
367
+ self.assertEqual(budget.get_spent_amount(), Decimal('5000.00'))
api/tests_new.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.urls import reverse
2
+ from rest_framework.test import APITestCase, APIClient
3
+ from rest_framework import status
4
+ from django.contrib.auth import get_user_model
5
+ from .models import Notification, SupportTicket
6
+
7
+ User = get_user_model()
8
+
9
+ class NotificationTests(APITestCase):
10
+ """Tests pour les notifications"""
11
+
12
+ def setUp(self):
13
+ self.user = User.objects.create_user(
14
+ email='test@example.com',
15
+ password='TestPass123!',
16
+ first_name='John',
17
+ last_name='Doe'
18
+ )
19
+ self.client = APIClient()
20
+ self.client.force_authenticate(user=self.user)
21
+ self.notifications_url = reverse('notification-list')
22
+
23
+ def test_create_notification(self):
24
+ """Test création de notification"""
25
+ data = {
26
+ 'title': 'Test Notification',
27
+ 'message': 'This is a test message',
28
+ 'type': 'system'
29
+ }
30
+ response = self.client.post(self.notifications_url, data)
31
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
32
+ self.assertEqual(Notification.objects.count(), 1)
33
+ self.assertEqual(Notification.objects.first().user, self.user)
34
+
35
+ def test_list_notifications(self):
36
+ """Test récupération de la liste des notifications"""
37
+ Notification.objects.create(
38
+ user=self.user,
39
+ title='Test Notification',
40
+ message='This is a test message',
41
+ type='system'
42
+ )
43
+ response = self.client.get(self.notifications_url)
44
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
45
+ self.assertEqual(len(response.data['results']), 1)
46
+
47
+ def test_mark_read(self):
48
+ """Test marquer une notification comme lue"""
49
+ notification = Notification.objects.create(
50
+ user=self.user,
51
+ title='Test Notification',
52
+ message='This is a test message',
53
+ type='system'
54
+ )
55
+ url = reverse('notification-mark-read', args=[notification.id])
56
+ response = self.client.patch(url)
57
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
58
+ notification.refresh_from_db()
59
+ self.assertTrue(notification.is_read)
60
+
61
+ def test_mark_all_read(self):
62
+ """Test marquer toutes les notifications comme lues"""
63
+ Notification.objects.create(
64
+ user=self.user,
65
+ title='Test Notification 1',
66
+ message='Message 1',
67
+ type='system'
68
+ )
69
+ Notification.objects.create(
70
+ user=self.user,
71
+ title='Test Notification 2',
72
+ message='Message 2',
73
+ type='system'
74
+ )
75
+ url = reverse('notification-mark-all-read')
76
+ response = self.client.patch(url)
77
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
78
+ self.assertEqual(Notification.objects.filter(is_read=True).count(), 2)
79
+
80
+
81
+ class SupportTicketTests(APITestCase):
82
+ """Tests pour les tickets support"""
83
+
84
+ def setUp(self):
85
+ self.user = User.objects.create_user(
86
+ email='test@example.com',
87
+ password='TestPass123!',
88
+ first_name='John',
89
+ last_name='Doe'
90
+ )
91
+ self.client = APIClient()
92
+ self.client.force_authenticate(user=self.user)
93
+ self.support_url = reverse('support-list')
94
+
95
+ def test_create_ticket(self):
96
+ """Test création de ticket"""
97
+ data = {
98
+ 'subject': 'Help me',
99
+ 'message': 'I need help'
100
+ }
101
+ response = self.client.post(self.support_url, data)
102
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
103
+ self.assertEqual(SupportTicket.objects.count(), 1)
104
+ self.assertEqual(SupportTicket.objects.first().user, self.user)
105
+
106
+ def test_list_tickets(self):
107
+ """Test récupération de la liste des tickets"""
108
+ SupportTicket.objects.create(
109
+ user=self.user,
110
+ subject='Help me',
111
+ message='I need help'
112
+ )
113
+ response = self.client.get(self.support_url)
114
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
115
+ self.assertEqual(len(response.data['results']), 1)
api/urls.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.urls import path, include
2
+ from rest_framework.routers import DefaultRouter
3
+ from rest_framework_simplejwt.views import TokenRefreshView
4
+
5
+ from .views import (
6
+ RegisterView, LoginView, ProfileView, ChangePasswordView,
7
+ ProductViewSet, TransactionViewSet, BudgetViewSet, AdViewSet,
8
+ NotificationViewSet, SupportTicketViewSet, VoiceCommandView, AIInsightsView,
9
+ analytics_overview, analytics_breakdown, analytics_kpi, analytics_activity,
10
+ analytics_balance_history
11
+ )
12
+
13
+ # Router pour les ViewSets
14
+ router = DefaultRouter()
15
+ router.register(r'products', ProductViewSet, basename='product')
16
+ router.register(r'transactions', TransactionViewSet, basename='transaction')
17
+ router.register(r'budgets', BudgetViewSet, basename='budget')
18
+ router.register(r'ads', AdViewSet, basename='ad')
19
+ router.register(r'notifications', NotificationViewSet, basename='notification')
20
+ router.register(r'support', SupportTicketViewSet, basename='support')
21
+
22
+ urlpatterns = [
23
+ # ===== AUTH =====
24
+ path('auth/register/', RegisterView.as_view(), name='register'),
25
+ path('auth/login/', LoginView.as_view(), name='login'),
26
+ path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
27
+ path('auth/me/', ProfileView.as_view(), name='profile'),
28
+ path('auth/change-password/', ChangePasswordView.as_view(), name='change-password'),
29
+
30
+ # ===== ANALYTICS =====
31
+ path('analytics/overview/', analytics_overview, name='analytics-overview'),
32
+ path('analytics/breakdown/', analytics_breakdown, name='analytics-breakdown'),
33
+ path('analytics/kpi/', analytics_kpi, name='analytics-kpi'),
34
+ path('analytics/activity/', analytics_activity, name='analytics-activity'),
35
+ path('analytics/balance-history/', analytics_balance_history, name='analytics-balance-history'),
36
+
37
+ # ===== ROUTER (Products, Transactions, Budgets, Ads) =====
38
+ path('', include(router.urls)),
39
+
40
+ # ===== VOICE AI =====
41
+ path('voice-command/', VoiceCommandView.as_view(), name='voice-command'),
42
+ path('ai-insights/', AIInsightsView.as_view(), name='ai-insights'),
43
+ ]
api/views.py ADDED
@@ -0,0 +1,839 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from rest_framework import viewsets, status, filters
2
+ from rest_framework.decorators import action, api_view, permission_classes
3
+ from rest_framework.response import Response
4
+ from rest_framework.permissions import IsAuthenticated, AllowAny
5
+ from rest_framework.views import APIView
6
+ from rest_framework_simplejwt.tokens import RefreshToken
7
+ from django.contrib.auth import get_user_model, authenticate
8
+ from django.db.models import Sum, Q, Count
9
+ from django.utils import timezone
10
+ from datetime import timedelta, datetime
11
+ from decimal import Decimal
12
+ from django_filters.rest_framework import DjangoFilterBackend
13
+ import csv
14
+ import hashlib
15
+ import json
16
+ from django.http import HttpResponse
17
+ from django.conf import settings
18
+
19
+ from .models import Product, Transaction, Budget, Ad, Notification, SupportTicket, AIInsight
20
+ from .serializers import (
21
+ UserSerializer, RegisterSerializer, ChangePasswordSerializer,
22
+ ProductSerializer, TransactionSerializer, TransactionSummarySerializer,
23
+ BudgetSerializer, AdSerializer, OverviewAnalyticsSerializer,
24
+ BreakdownAnalyticsSerializer, KPISerializer, ActivityAnalyticsSerializer,
25
+ BalanceHistorySerializer, NotificationSerializer, SupportTicketSerializer
26
+ )
27
+ from .gemini_service import GeminiService
28
+ from .groq_service import GroqService
29
+ from .assemblyai_service import AssemblyAIService
30
+ import tempfile
31
+ import os
32
+
33
+ User = get_user_model()
34
+
35
+
36
+ # ========== AUTHENTIFICATION ==========
37
+
38
+ class RegisterView(APIView):
39
+ """Inscription d'un nouvel utilisateur"""
40
+ permission_classes = [AllowAny]
41
+
42
+ def post(self, request):
43
+ serializer = RegisterSerializer(data=request.data)
44
+ if serializer.is_valid():
45
+ user = serializer.save()
46
+ refresh = RefreshToken.for_user(user)
47
+
48
+ return Response({
49
+ 'user': UserSerializer(user, context={'request': request}).data,
50
+ 'tokens': {
51
+ 'refresh': str(refresh),
52
+ 'access': str(refresh.access_token),
53
+ }
54
+ }, status=status.HTTP_201_CREATED)
55
+
56
+ return Response({
57
+ 'type': 'validation_error',
58
+ 'errors': serializer.errors
59
+ }, status=status.HTTP_400_BAD_REQUEST)
60
+
61
+
62
+ class LoginView(APIView):
63
+ """Connexion via email et mot de passe"""
64
+ permission_classes = [AllowAny]
65
+
66
+ def post(self, request):
67
+ email = request.data.get('email')
68
+ password = request.data.get('password')
69
+
70
+ if not email or not password:
71
+ return Response({
72
+ 'type': 'validation_error',
73
+ 'errors': {
74
+ 'email': ['Email et mot de passe requis.']
75
+ }
76
+ }, status=status.HTTP_400_BAD_REQUEST)
77
+
78
+ # Authenticate avec email
79
+ try:
80
+ user = User.objects.get(email=email)
81
+ except User.DoesNotExist:
82
+ return Response({
83
+ 'type': 'validation_error',
84
+ 'errors': {
85
+ 'email': ['Email ou mot de passe incorrect.']
86
+ }
87
+ }, status=status.HTTP_401_UNAUTHORIZED)
88
+
89
+ if not user.check_password(password):
90
+ return Response({
91
+ 'type': 'validation_error',
92
+ 'errors': {
93
+ 'password': ['Email ou mot de passe incorrect.']
94
+ }
95
+ }, status=status.HTTP_401_UNAUTHORIZED)
96
+
97
+ if not user.is_active:
98
+ return Response({
99
+ 'type': 'validation_error',
100
+ 'errors': {
101
+ 'email': ['Ce compte est désactivé.']
102
+ }
103
+ }, status=status.HTTP_403_FORBIDDEN)
104
+
105
+ refresh = RefreshToken.for_user(user)
106
+
107
+ return Response({
108
+ 'user': UserSerializer(user, context={'request': request}).data,
109
+ 'tokens': {
110
+ 'refresh': str(refresh),
111
+ 'access': str(refresh.access_token),
112
+ }
113
+ })
114
+
115
+
116
+ class ProfileView(APIView):
117
+ """Récupération et mise à jour du profil"""
118
+ permission_classes = [IsAuthenticated]
119
+
120
+ def get(self, request):
121
+ serializer = UserSerializer(request.user, context={'request': request})
122
+ return Response(serializer.data)
123
+
124
+ def patch(self, request):
125
+ serializer = UserSerializer(
126
+ request.user,
127
+ data=request.data,
128
+ partial=True,
129
+ context={'request': request}
130
+ )
131
+ if serializer.is_valid():
132
+ serializer.save()
133
+ return Response(serializer.data)
134
+
135
+ return Response({
136
+ 'type': 'validation_error',
137
+ 'errors': serializer.errors
138
+ }, status=status.HTTP_400_BAD_REQUEST)
139
+
140
+
141
+ class ChangePasswordView(APIView):
142
+ """Changement de mot de passe"""
143
+ permission_classes = [IsAuthenticated]
144
+
145
+ def post(self, request):
146
+ serializer = ChangePasswordSerializer(data=request.data)
147
+
148
+ if serializer.is_valid():
149
+ user = request.user
150
+
151
+ if not user.check_password(serializer.validated_data['old_password']):
152
+ return Response({
153
+ 'type': 'validation_error',
154
+ 'errors': {
155
+ 'old_password': ['Mot de passe actuel incorrect.']
156
+ }
157
+ }, status=status.HTTP_400_BAD_REQUEST)
158
+
159
+ user.set_password(serializer.validated_data['new_password'])
160
+ user.save()
161
+
162
+ return Response({
163
+ 'message': 'Mot de passe modifié avec succès.'
164
+ })
165
+
166
+ return Response({
167
+ 'type': 'validation_error',
168
+ 'errors': serializer.errors
169
+ }, status=status.HTTP_400_BAD_REQUEST)
170
+
171
+
172
+ # ========== PRODUITS ==========
173
+
174
+ class ProductViewSet(viewsets.ModelViewSet):
175
+ """CRUD pour les produits"""
176
+ serializer_class = ProductSerializer
177
+ permission_classes = [IsAuthenticated]
178
+ filter_backends = [DjangoFilterBackend, filters.SearchFilter]
179
+ filterset_fields = ['category', 'stock_status']
180
+ search_fields = ['name', 'description']
181
+
182
+ def get_queryset(self):
183
+ return Product.objects.filter(user=self.request.user)
184
+
185
+ @action(detail=False, methods=['get'])
186
+ def export(self, request):
187
+ """Export CSV des produits"""
188
+ products = self.get_queryset()
189
+
190
+ response = HttpResponse(content_type='text/csv')
191
+ response['Content-Disposition'] = 'attachment; filename="products.csv"'
192
+
193
+ writer = csv.writer(response)
194
+ writer.writerow(['Nom', 'Description', 'Prix', 'Unité', 'Catégorie', 'Stock'])
195
+
196
+ for product in products:
197
+ writer.writerow([
198
+ product.name,
199
+ product.description,
200
+ product.price,
201
+ product.unit,
202
+ product.get_category_display(),
203
+ product.get_stock_status_display()
204
+ ])
205
+
206
+ return response
207
+
208
+
209
+ # ========== TRANSACTIONS ==========
210
+
211
+ class TransactionViewSet(viewsets.ModelViewSet):
212
+ """CRUD pour les transactions"""
213
+ serializer_class = TransactionSerializer
214
+ permission_classes = [IsAuthenticated]
215
+ filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
216
+ filterset_fields = ['type', 'category']
217
+ search_fields = ['name', 'category']
218
+ ordering_fields = ['date', 'amount']
219
+ ordering = ['-date']
220
+
221
+ def get_queryset(self):
222
+ queryset = Transaction.objects.filter(user=self.request.user)
223
+
224
+ # Filtre par date range
225
+ date_range = self.request.query_params.get('date_range')
226
+ if date_range:
227
+ now = timezone.now()
228
+ if date_range == 'today':
229
+ start_date = now.replace(hour=0, minute=0, second=0)
230
+ elif date_range == 'week':
231
+ start_date = now - timedelta(days=7)
232
+ elif date_range == 'month':
233
+ start_date = now - timedelta(days=30)
234
+ elif date_range == 'year':
235
+ start_date = now - timedelta(days=365)
236
+ else:
237
+ start_date = None
238
+
239
+ if start_date:
240
+ queryset = queryset.filter(date__gte=start_date)
241
+
242
+ return queryset
243
+
244
+ @action(detail=False, methods=['get'])
245
+ def summary(self, request):
246
+ """Résumé pour le dashboard"""
247
+ user = request.user
248
+ now = timezone.now()
249
+ yesterday = now - timedelta(days=1)
250
+ day_before = now - timedelta(days=2)
251
+
252
+ # Transactions des dernières 24h
253
+ recent = Transaction.objects.filter(
254
+ user=user,
255
+ date__gte=yesterday
256
+ )
257
+
258
+ # Transactions des 24h précédentes
259
+ previous = Transaction.objects.filter(
260
+ user=user,
261
+ date__gte=day_before,
262
+ date__lt=yesterday
263
+ )
264
+
265
+ # Calculs
266
+ income_24h = recent.filter(type='income').aggregate(
267
+ total=Sum('amount')
268
+ )['total'] or Decimal('0.00')
269
+
270
+ expenses_24h = recent.filter(type='expense').aggregate(
271
+ total=Sum('amount')
272
+ )['total'] or Decimal('0.00')
273
+
274
+ prev_income = previous.filter(type='income').aggregate(
275
+ total=Sum('amount')
276
+ )['total'] or Decimal('0.00')
277
+
278
+ prev_expenses = previous.filter(type='expense').aggregate(
279
+ total=Sum('amount')
280
+ )['total'] or Decimal('0.00')
281
+
282
+ # Balance totale
283
+ total_income = Transaction.objects.filter(
284
+ user=user, type='income'
285
+ ).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
286
+
287
+ total_expenses = Transaction.objects.filter(
288
+ user=user, type='expense'
289
+ ).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
290
+
291
+ balance = user.initial_balance + total_income - total_expenses
292
+
293
+ # Variations en %
294
+ def calc_variation(current, previous):
295
+ if previous > 0:
296
+ return float(((current - previous) / previous) * 100)
297
+ return 0.0
298
+
299
+ data = {
300
+ 'balance': balance,
301
+ 'income_24h': income_24h,
302
+ 'expenses_24h': expenses_24h,
303
+ 'income_variation': calc_variation(income_24h, prev_income),
304
+ 'expenses_variation': calc_variation(expenses_24h, prev_expenses)
305
+ }
306
+
307
+ serializer = TransactionSummarySerializer(data)
308
+ return Response(serializer.data)
309
+
310
+
311
+ # ========== ANALYTICS ==========
312
+
313
+ class AnalyticsView(APIView):
314
+ """Analytics pour le dashboard"""
315
+ permission_classes = [IsAuthenticated]
316
+
317
+ def get_overview(self, request):
318
+ """Graphique barres: Revenus vs Dépenses par mois"""
319
+ user = request.user
320
+ now = timezone.now()
321
+ six_months_ago = now - timedelta(days=180)
322
+
323
+ transactions = Transaction.objects.filter(
324
+ user=user,
325
+ date__gte=six_months_ago
326
+ )
327
+
328
+ # Grouper par mois
329
+ monthly_data = {}
330
+ for t in transactions:
331
+ month_key = t.date.strftime('%Y-%m')
332
+ if month_key not in monthly_data:
333
+ monthly_data[month_key] = {'income': Decimal('0.00'), 'expenses': Decimal('0.00')}
334
+
335
+ if t.type == 'income':
336
+ monthly_data[month_key]['income'] += t.amount
337
+ else:
338
+ monthly_data[month_key]['expenses'] += t.amount
339
+
340
+ # Formater pour le serializer
341
+ result = []
342
+ for month, data in sorted(monthly_data.items()):
343
+ result.append({
344
+ 'month': datetime.strptime(month, '%Y-%m').strftime('%b %Y'),
345
+ 'income': data['income'],
346
+ 'expenses': data['expenses']
347
+ })
348
+
349
+ serializer = OverviewAnalyticsSerializer(result, many=True)
350
+ return Response(serializer.data)
351
+
352
+ def get_breakdown(self, request):
353
+ """Graphique camembert: Dépenses par catégorie"""
354
+ user = request.user
355
+
356
+ expenses = Transaction.objects.filter(
357
+ user=user,
358
+ type='expense'
359
+ ).values('category').annotate(
360
+ total=Sum('amount')
361
+ ).order_by('-total')
362
+
363
+ total_expenses = sum(item['total'] for item in expenses)
364
+
365
+ result = []
366
+ for item in expenses:
367
+ percentage = float((item['total'] / total_expenses) * 100) if total_expenses > 0 else 0
368
+ result.append({
369
+ 'category': item['category'],
370
+ 'amount': item['total'],
371
+ 'percentage': percentage
372
+ })
373
+
374
+ serializer = BreakdownAnalyticsSerializer(result, many=True)
375
+ return Response(serializer.data)
376
+
377
+ def get_kpi(self, request):
378
+ """KPIs clés avec calcul de croissance"""
379
+ user = request.user
380
+ now = timezone.now()
381
+ month_ago = now - timedelta(days=30)
382
+ two_months_ago = now - timedelta(days=60)
383
+
384
+ # --- Période Actuelle (30 derniers jours) ---
385
+ current_income_tx = Transaction.objects.filter(
386
+ user=user, type='income', date__gte=month_ago
387
+ )
388
+ current_total_income = current_income_tx.aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')
389
+ current_count_income = current_income_tx.count()
390
+ current_avg_basket = current_total_income / current_count_income if current_count_income > 0 else Decimal('0.00')
391
+
392
+ current_marketing = Transaction.objects.filter(
393
+ user=user, type='expense', category__icontains='marketing', date__gte=month_ago
394
+ ).aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')
395
+
396
+ # --- Période Précédente (30 à 60 jours) ---
397
+ prev_income_tx = Transaction.objects.filter(
398
+ user=user, type='income', date__gte=two_months_ago, date__lt=month_ago
399
+ )
400
+ prev_total_income = prev_income_tx.aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')
401
+ prev_count_income = prev_income_tx.count()
402
+ prev_avg_basket = prev_total_income / prev_count_income if prev_count_income > 0 else Decimal('0.00')
403
+
404
+ prev_marketing = Transaction.objects.filter(
405
+ user=user, type='expense', category__icontains='marketing', date__gte=two_months_ago, date__lt=month_ago
406
+ ).aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')
407
+
408
+ # --- Calcul des Croissances ---
409
+ def calc_growth(current, prev):
410
+ if prev == 0: return 100.0 if current > 0 else 0.0
411
+ return float(((current - prev) / prev) * 100)
412
+
413
+ data = {
414
+ 'average_basket': current_avg_basket,
415
+ 'average_basket_growth': calc_growth(current_avg_basket, prev_avg_basket),
416
+ 'estimated_mrr': current_total_income,
417
+ 'estimated_mrr_growth': calc_growth(current_total_income, prev_total_income),
418
+ 'cac': current_marketing,
419
+ 'cac_growth': calc_growth(current_marketing, prev_marketing)
420
+ }
421
+
422
+ serializer = KPISerializer(data)
423
+ return Response(serializer.data)
424
+
425
+ def get_activity(self, request):
426
+ """Graphique d'activité: Ventes des 7 derniers jours"""
427
+ user = request.user
428
+ now = timezone.now().date()
429
+ days = []
430
+
431
+ # Récupérer les 7 derniers jours
432
+ for i in range(6, -1, -1):
433
+ day = now - timedelta(days=i)
434
+ total_sales = Transaction.objects.filter(
435
+ user=user,
436
+ type='income',
437
+ date=day
438
+ ).aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')
439
+
440
+ days.append({
441
+ 'day': day.strftime('%a'), # Lun, Mar, etc.
442
+ 'sales': total_sales
443
+ })
444
+
445
+ serializer = ActivityAnalyticsSerializer(days, many=True)
446
+ return Response(serializer.data)
447
+
448
+ def get_balance_history(self, request):
449
+ """Historique du solde cumulé"""
450
+ user = request.user
451
+ # Récupérer toutes les transactions triées par date
452
+ transactions = Transaction.objects.filter(user=user).order_by('date')
453
+
454
+ history = []
455
+ running_balance = user.initial_balance
456
+
457
+ # Grouper par date pour éviter d'avoir trop de points si plusieurs transactions le même jour
458
+ daily_balances = {}
459
+ for t in transactions:
460
+ if t.type == 'income':
461
+ running_balance += t.amount
462
+ else:
463
+ running_balance -= t.amount
464
+
465
+ daily_balances[t.date] = running_balance
466
+
467
+ # Formater pour le frontend
468
+ for date in sorted(daily_balances.keys()):
469
+ history.append({
470
+ 'date': date.strftime('%d/%m'),
471
+ 'balance': daily_balances[date]
472
+ })
473
+
474
+ # Si pas de transactions, ajouter un point à zéro
475
+ if not history:
476
+ history.append({'date': timezone.now().strftime('%d/%m'), 'balance': Decimal('0.00')})
477
+
478
+ serializer = BalanceHistorySerializer(history, many=True)
479
+ return Response(serializer.data)
480
+
481
+
482
+ @api_view(['GET'])
483
+ @permission_classes([IsAuthenticated])
484
+ def analytics_overview(request):
485
+ view = AnalyticsView()
486
+ return view.get_overview(request)
487
+
488
+
489
+ @api_view(['GET'])
490
+ @permission_classes([IsAuthenticated])
491
+ def analytics_breakdown(request):
492
+ view = AnalyticsView()
493
+ return view.get_breakdown(request)
494
+
495
+
496
+ @api_view(['GET'])
497
+ @permission_classes([IsAuthenticated])
498
+ def analytics_kpi(request):
499
+ view = AnalyticsView()
500
+ return view.get_kpi(request)
501
+
502
+
503
+ @api_view(['GET'])
504
+ @permission_classes([IsAuthenticated])
505
+ def analytics_activity(request):
506
+ view = AnalyticsView()
507
+ return view.get_activity(request)
508
+
509
+
510
+ @api_view(['GET'])
511
+ @permission_classes([IsAuthenticated])
512
+ def analytics_balance_history(request):
513
+ view = AnalyticsView()
514
+ return view.get_balance_history(request)
515
+
516
+
517
+ # ========== BUDGETS ==========
518
+
519
+ class BudgetViewSet(viewsets.ModelViewSet):
520
+ """CRUD pour les budgets"""
521
+ serializer_class = BudgetSerializer
522
+ permission_classes = [IsAuthenticated]
523
+
524
+ def get_queryset(self):
525
+ return Budget.objects.filter(user=self.request.user)
526
+
527
+
528
+ # ========== ANNONCES ==========
529
+
530
+ class AdViewSet(viewsets.ModelViewSet):
531
+ """CRUD pour les annonces"""
532
+ serializer_class = AdSerializer
533
+ permission_classes = [IsAuthenticated]
534
+ filter_backends = [filters.SearchFilter]
535
+ search_fields = ['product_name', 'owner_name', 'description', 'location']
536
+
537
+ def get_queryset(self):
538
+ # Les annonces sont publiques mais filtrées par vérification
539
+ return Ad.objects.filter(is_verified=True)
540
+
541
+ def get_permissions(self):
542
+ # Lecture publique, écriture authentifiée
543
+ if self.action in ['list', 'retrieve']:
544
+ return [AllowAny()]
545
+ return [IsAuthenticated()]
546
+
547
+
548
+ # ========== NOTIFICATIONS ==========
549
+
550
+ class NotificationViewSet(viewsets.ModelViewSet):
551
+ """CRUD pour les notifications"""
552
+ serializer_class = NotificationSerializer
553
+ permission_classes = [IsAuthenticated]
554
+
555
+ def get_queryset(self):
556
+ return Notification.objects.filter(user=self.request.user)
557
+
558
+ @action(detail=True, methods=['patch'])
559
+ def mark_read(self, request, pk=None):
560
+ notification = self.get_object()
561
+ notification.is_read = True
562
+ notification.save()
563
+ return Response({'status': 'marked as read'})
564
+
565
+ @action(detail=False, methods=['patch'])
566
+ def mark_all_read(self, request):
567
+ self.get_queryset().update(is_read=True)
568
+ return Response({'status': 'all marked as read'})
569
+
570
+ def perform_create(self, serializer):
571
+ serializer.save(user=self.request.user)
572
+
573
+
574
+ # ========== SUPPORT ==========
575
+
576
+ class SupportTicketViewSet(viewsets.ModelViewSet):
577
+ """CRUD pour les tickets support"""
578
+ serializer_class = SupportTicketSerializer
579
+ permission_classes = [IsAuthenticated]
580
+
581
+ def get_queryset(self):
582
+ return SupportTicket.objects.filter(user=self.request.user)
583
+
584
+ def perform_create(self, serializer):
585
+ serializer.save(user=self.request.user)
586
+
587
+
588
+ # ========== VOICE AI ==========
589
+
590
+ class VoiceCommandView(APIView):
591
+ """Traitement des commandes vocales via Gemini"""
592
+ permission_classes = [IsAuthenticated]
593
+
594
+ def post(self, request):
595
+ audio_file = request.FILES.get('audio')
596
+ text_command = request.data.get('text')
597
+
598
+ if not audio_file and not text_command:
599
+ return Response({'error': 'No audio file or text command provided'}, status=status.HTTP_400_BAD_REQUEST)
600
+
601
+ try:
602
+ service = GeminiService()
603
+
604
+ # Fetch user products for context
605
+ user_products = Product.objects.filter(user=request.user)
606
+ products_list = [
607
+ {"name": p.name, "price": float(p.price), "unit": p.unit}
608
+ for p in user_products
609
+ ]
610
+ print(f"VoiceCommandView - Context Products: {products_list}")
611
+
612
+ debug_url = None
613
+ if audio_file:
614
+ # --- DEBUG: Sauvegarder l'audio pour vérification ---
615
+ try:
616
+ debug_dir = os.path.join(settings.MEDIA_ROOT, 'debug_voice')
617
+ os.makedirs(debug_dir, exist_ok=True)
618
+
619
+ # Nettoyer le nom du fichier et ajouter un timestamp
620
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
621
+ debug_filename = f"voice_{timestamp}_{audio_file.name}"
622
+ debug_path = os.path.join(debug_dir, debug_filename)
623
+
624
+ with open(debug_path, 'wb+') as destination:
625
+ for chunk in audio_file.chunks():
626
+ destination.write(chunk)
627
+
628
+ debug_url = f"{request.build_absolute_uri(settings.MEDIA_URL)}debug_voice/{debug_filename}"
629
+ print(f"DEBUG AUDIO SAVED: {debug_path}")
630
+
631
+ # Important: Réinitialiser le curseur après la sauvegarde pour Groq/Gemini
632
+ audio_file.seek(0)
633
+ except Exception as e:
634
+ print(f"Error saving debug audio: {e}")
635
+ # --- FIN DEBUG ---
636
+
637
+ # Primary attempt: AssemblyAI (User's choice)
638
+ print(f"Using AssemblyAI STT as primary")
639
+ user_lang = getattr(request.user, 'language', 'fr').lower()
640
+ audio_file.seek(0)
641
+ assembly_service = AssemblyAIService()
642
+ transcription = assembly_service.transcribe(audio_file, language=user_lang)
643
+ used_provider = "AssemblyAI"
644
+
645
+ if not transcription:
646
+ # Fallback 1: Groq
647
+ print(f"AssemblyAI STT failed, trying Groq as fallback with language: {user_lang}")
648
+ audio_file.seek(0)
649
+ groq_service = GroqService()
650
+ transcription = groq_service.transcribe(audio_file, language=user_lang)
651
+ used_provider = "Groq"
652
+
653
+ if transcription:
654
+ print(f"Transcription successful ({used_provider}): {transcription}")
655
+
656
+ # LLM Processing Fallback Chain
657
+ result = None
658
+ groq_service = GroqService() # reusing the service
659
+
660
+ # Try Llama 3.3 70B
661
+ print("Attempting processing with llama-3.3-70b-versatile...")
662
+ result = groq_service.process_text_command(transcription, context_products=products_list, model="llama-3.3-70b-versatile")
663
+
664
+ if not result:
665
+ # Try Llama 3.1 8B
666
+ print("Llama 3.3 70B failed, attempting with llama-3.1-8b-instant...")
667
+ result = groq_service.process_text_command(transcription, context_products=products_list, model="llama-3.1-8b-instant")
668
+
669
+ if not result:
670
+ # Final resort: Gemini
671
+ print("All Groq LLMs failed, falling back to Gemini...")
672
+ gemini_service = GeminiService()
673
+ result = gemini_service.process_text_command(transcription, context_products=products_list)
674
+ else:
675
+ # Fallback 2: Gemini's native voice processing
676
+ print("All STT services failed, falling back to Gemini native voice command")
677
+ audio_file.seek(0)
678
+ audio_bytes = audio_file.read()
679
+ mime_type = audio_file.content_type or 'audio/mp3'
680
+ gemini_service = GeminiService()
681
+ result = gemini_service.process_voice_command(audio_bytes, mime_type, context_products=products_list)
682
+ else:
683
+ # Direct text command processing with the same chain
684
+ groq_service = GroqService()
685
+ print("Processing text command with llama-3.3-70b-versatile...")
686
+ result = groq_service.process_text_command(text_command, context_products=products_list, model="llama-3.3-70b-versatile")
687
+
688
+ if not result:
689
+ print("Llama 3.3 70B failed, trying llama-3.1-8b-instant...")
690
+ result = groq_service.process_text_command(text_command, context_products=products_list, model="llama-3.1-8b-instant")
691
+
692
+ if not result:
693
+ print("All Groq LLMs failed, falling back to Gemini...")
694
+ gemini_service = GeminiService()
695
+ result = gemini_service.process_text_command(text_command, context_products=products_list)
696
+
697
+ print(f"VoiceCommandView - Result Intent: {result.get('intent')}")
698
+
699
+ if result.get('intent') == 'create_transaction':
700
+ data = result.get('data', {})
701
+ print(f"VoiceCommandView - Transaction Data: {data}")
702
+
703
+ # Ensure date is a full datetime string for TransactionSerializer
704
+ raw_date = data.get('date')
705
+ final_datetime = timezone.now() # Already aware if USE_TZ=True
706
+
707
+ if raw_date:
708
+ try:
709
+ # If AI gives YYYY-MM-DD, combine with current time
710
+ parsed_date = datetime.strptime(raw_date, '%Y-%m-%d').date()
711
+ # Make sure we create an aware datetime to avoid warnings
712
+ naive_dt = datetime.combine(parsed_date, timezone.now().time())
713
+ final_datetime = timezone.make_aware(naive_dt)
714
+ except:
715
+ pass
716
+
717
+ # Prepare naming with fallback to transcription
718
+ transcription_text = result.get('transcription', '')
719
+ default_name = (transcription_text[:20] + '...') if len(transcription_text) > 20 else transcription_text
720
+
721
+ transaction_data = {
722
+ 'name': data.get('name') or default_name or 'Transaction Vocale',
723
+ 'amount': data.get('amount'),
724
+ 'type': data.get('type'),
725
+ 'category': data.get('category', 'Divers'),
726
+ 'currency': data.get('currency', 'FCFA'),
727
+ 'date': final_datetime
728
+ }
729
+
730
+ # Use serializer to validate and save
731
+ # We need to pass context={'request': request} so that create() method can access user
732
+ serializer = TransactionSerializer(data=transaction_data, context={'request': request})
733
+
734
+ if serializer.is_valid():
735
+ print("VoiceCommandView - Serializer is valid. Saving...")
736
+ serializer.save()
737
+ return Response({
738
+ 'status': 'success',
739
+ 'transcription': result.get('transcription'),
740
+ 'transaction': serializer.data,
741
+ 'debug_audio_url': debug_url
742
+ })
743
+ else:
744
+ print(f"VoiceCommandView - Serializer Errors: {serializer.errors}")
745
+ return Response({
746
+ 'status': 'error',
747
+ 'transcription': result.get('transcription'),
748
+ 'message': 'Validation failed',
749
+ 'errors': serializer.errors
750
+ }, status=status.HTTP_400_BAD_REQUEST)
751
+
752
+ elif result.get('intent') == 'create_product':
753
+ data = result.get('data', {})
754
+ print(f"VoiceCommandView - Product Data: {data}")
755
+
756
+ product_data = {
757
+ 'name': data.get('name'),
758
+ 'price': data.get('price'),
759
+ 'unit': data.get('unit') or 'unité',
760
+ 'description': data.get('description') or '',
761
+ 'category': data.get('category') or 'stock',
762
+ 'stock_status': data.get('stock_status') or 'ok'
763
+ }
764
+
765
+ # Map common AI terms to valid choices if needed
766
+ if product_data['stock_status'] == 'instock': product_data['stock_status'] = 'ok'
767
+ if product_data['stock_status'] == 'outofstock': product_data['stock_status'] = 'rupture'
768
+
769
+ serializer = ProductSerializer(data=product_data, context={'request': request})
770
+ if serializer.is_valid():
771
+ print("VoiceCommandView - Product Serializer is valid. Saving...")
772
+ serializer.save()
773
+ return Response({
774
+ 'status': 'success',
775
+ 'transcription': result.get('transcription'),
776
+ 'product': serializer.data,
777
+ 'debug_audio_url': debug_url
778
+ })
779
+ else:
780
+ print(f"VoiceCommandView - Product Serializer Errors: {serializer.errors}")
781
+ return Response({
782
+ 'status': 'error',
783
+ 'transcription': result.get('transcription'),
784
+ 'message': 'Product validation failed',
785
+ 'errors': serializer.errors
786
+ }, status=status.HTTP_400_BAD_REQUEST)
787
+
788
+ return Response({
789
+ 'status': 'processed',
790
+ 'transcription': result.get('transcription'),
791
+ 'intent': result.get('intent'),
792
+ 'data': result.get('data'),
793
+ 'error': result.get('error'),
794
+ 'debug_audio_url': debug_url
795
+ })
796
+
797
+ except Exception as e:
798
+ return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
799
+
800
+
801
+ class AIInsightsView(APIView):
802
+ """Génération d'insights financiers via Gemini avec mise en mémoire en base de données"""
803
+ permission_classes = [IsAuthenticated]
804
+
805
+ def post(self, request):
806
+ context_data = request.data.get('context', {})
807
+
808
+ # Calculer un hash du contexte pour détecter les changements
809
+ context_str = json.dumps(context_data, sort_keys=True)
810
+ context_hash = hashlib.sha256(context_str.encode()).hexdigest()
811
+
812
+ # Vérifier si un insight existe déjà pour ce contexte et cet utilisateur
813
+ existing_insight = AIInsight.objects.filter(
814
+ user=request.user,
815
+ context_hash=context_hash
816
+ ).first()
817
+
818
+ if existing_insight:
819
+ return Response({'insights': existing_insight.content, 'cached': True})
820
+
821
+ try:
822
+ service = GeminiService()
823
+ insights = service.process_insights(context_data)
824
+
825
+ # Sauvegarder le nouvel insight
826
+ AIInsight.objects.create(
827
+ user=request.user,
828
+ content=insights,
829
+ context_hash=context_hash
830
+ )
831
+
832
+ return Response({'insights': insights, 'cached': False})
833
+ except Exception as e:
834
+ # En cas d'erreur de l'IA, essayer de renvoyer le dernier insight connu
835
+ last_insight = AIInsight.objects.filter(user=request.user).first()
836
+ if last_insight:
837
+ return Response({'insights': last_insight.content, 'cached': True, 'error_fallback': str(e)})
838
+
839
+ return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
manage.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Django's command-line utility for administrative tasks."""
3
+ import os
4
+ import sys
5
+
6
+
7
+ def main():
8
+ """Run administrative tasks."""
9
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Akompta.settings')
10
+ try:
11
+ from django.core.management import execute_from_command_line
12
+ except ImportError as exc:
13
+ raise ImportError(
14
+ "Couldn't import Django. Are you sure it's installed and "
15
+ "available on your PYTHONPATH environment variable? Did you "
16
+ "forget to activate a virtual environment?"
17
+ ) from exc
18
+ execute_from_command_line(sys.argv)
19
+
20
+
21
+ if __name__ == '__main__':
22
+ main()
requirements.txt ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ annotated-types==0.7.0
2
+ anyio==4.12.0
3
+ asgiref==3.11.0
4
+ cachetools==6.2.2
5
+ certifi==2025.11.12
6
+ charset-normalizer==3.4.4
7
+ Django==5.2.8
8
+ django-cors-headers==4.3.1
9
+ django-filter==23.5
10
+ djangorestframework==3.14.0
11
+ djangorestframework-simplejwt==5.3.1
12
+ google-auth==2.43.0
13
+ google-genai==1.52.0
14
+ gunicorn==21.2.0
15
+ h11==0.16.0
16
+ httpcore==1.0.9
17
+ httpx==0.28.1
18
+ idna==3.11
19
+ packaging==25.0
20
+ pillow>=11.0.0
21
+ psycopg2-binary==2.9.9
22
+ pyasn1==0.6.1
23
+ pyasn1_modules==0.4.2
24
+ pydantic==2.12.5
25
+ pydantic_core==2.41.5
26
+ PyJWT==2.10.1
27
+ python-decouple==3.8
28
+ pytz==2025.2
29
+ requests==2.32.5
30
+ rsa==4.9.1
31
+ sqlparse==0.5.3
32
+ tenacity==9.1.2
33
+ typing-inspection==0.4.2
34
+ typing_extensions==4.15.0
35
+ urllib3==2.5.0
36
+ websockets==15.0.1
37
+ whitenoise==6.6.0
38
+ groq==1.0.0
39
+ assemblyai
start.sh ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Exit on error
4
+ set -e
5
+
6
+ echo "Starting Akompta Backend Setup..."
7
+
8
+ # Apply database migrations
9
+ echo "Applying database migrations..."
10
+ python manage.py migrate --noinput
11
+
12
+ # Collect static files
13
+ echo "Collecting static files..."
14
+ python manage.py collectstatic --noinput
15
+
16
+ # Start Gunicorn
17
+ echo "Starting Gunicorn..."
18
+ exec gunicorn --bind 0.0.0.0:7860 --workers 3 --timeout 120 Akompta.wsgi:application
static/admin/css/autocomplete.css ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ select.admin-autocomplete {
2
+ width: 20em;
3
+ }
4
+
5
+ .select2-container--admin-autocomplete.select2-container {
6
+ min-height: 30px;
7
+ }
8
+
9
+ .select2-container--admin-autocomplete .select2-selection--single,
10
+ .select2-container--admin-autocomplete .select2-selection--multiple {
11
+ min-height: 30px;
12
+ padding: 0;
13
+ }
14
+
15
+ .select2-container--admin-autocomplete.select2-container--focus .select2-selection,
16
+ .select2-container--admin-autocomplete.select2-container--open .select2-selection {
17
+ border-color: var(--body-quiet-color);
18
+ min-height: 30px;
19
+ }
20
+
21
+ .select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single,
22
+ .select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single {
23
+ padding: 0;
24
+ }
25
+
26
+ .select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple,
27
+ .select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple {
28
+ padding: 0;
29
+ }
30
+
31
+ .select2-container--admin-autocomplete .select2-selection--single {
32
+ background-color: var(--body-bg);
33
+ border: 1px solid var(--border-color);
34
+ border-radius: 4px;
35
+ }
36
+
37
+ .select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered {
38
+ color: var(--body-fg);
39
+ line-height: 30px;
40
+ }
41
+
42
+ .select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear {
43
+ cursor: pointer;
44
+ float: right;
45
+ font-weight: bold;
46
+ }
47
+
48
+ .select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder {
49
+ color: var(--body-quiet-color);
50
+ }
51
+
52
+ .select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow {
53
+ height: 26px;
54
+ position: absolute;
55
+ top: 1px;
56
+ right: 1px;
57
+ width: 20px;
58
+ }
59
+
60
+ .select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b {
61
+ border-color: #888 transparent transparent transparent;
62
+ border-style: solid;
63
+ border-width: 5px 4px 0 4px;
64
+ height: 0;
65
+ left: 50%;
66
+ margin-left: -4px;
67
+ margin-top: -2px;
68
+ position: absolute;
69
+ top: 50%;
70
+ width: 0;
71
+ }
72
+
73
+ .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear {
74
+ float: left;
75
+ }
76
+
77
+ .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow {
78
+ left: 1px;
79
+ right: auto;
80
+ }
81
+
82
+ .select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single {
83
+ background-color: var(--darkened-bg);
84
+ cursor: default;
85
+ }
86
+
87
+ .select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear {
88
+ display: none;
89
+ }
90
+
91
+ .select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b {
92
+ border-color: transparent transparent #888 transparent;
93
+ border-width: 0 4px 5px 4px;
94
+ }
95
+
96
+ .select2-container--admin-autocomplete .select2-selection--multiple {
97
+ background-color: var(--body-bg);
98
+ border: 1px solid var(--border-color);
99
+ border-radius: 4px;
100
+ cursor: text;
101
+ }
102
+
103
+ .select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered {
104
+ box-sizing: border-box;
105
+ list-style: none;
106
+ margin: 0;
107
+ padding: 0 10px 5px 5px;
108
+ width: 100%;
109
+ display: flex;
110
+ flex-wrap: wrap;
111
+ }
112
+
113
+ .select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li {
114
+ list-style: none;
115
+ }
116
+
117
+ .select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder {
118
+ color: var(--body-quiet-color);
119
+ margin-top: 5px;
120
+ float: left;
121
+ }
122
+
123
+ .select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear {
124
+ cursor: pointer;
125
+ float: right;
126
+ font-weight: bold;
127
+ margin: 5px;
128
+ position: absolute;
129
+ right: 0;
130
+ }
131
+
132
+ .select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice {
133
+ background-color: var(--darkened-bg);
134
+ border: 1px solid var(--border-color);
135
+ border-radius: 4px;
136
+ cursor: default;
137
+ float: left;
138
+ margin-right: 5px;
139
+ margin-top: 5px;
140
+ padding: 0 5px;
141
+ }
142
+
143
+ .select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove {
144
+ color: var(--body-quiet-color);
145
+ cursor: pointer;
146
+ display: inline-block;
147
+ font-weight: bold;
148
+ margin-right: 2px;
149
+ }
150
+
151
+ .select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover {
152
+ color: var(--body-fg);
153
+ }
154
+
155
+ .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline {
156
+ float: right;
157
+ }
158
+
159
+ .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
160
+ margin-left: 5px;
161
+ margin-right: auto;
162
+ }
163
+
164
+ .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
165
+ margin-left: 2px;
166
+ margin-right: auto;
167
+ }
168
+
169
+ .select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple {
170
+ border: solid var(--body-quiet-color) 1px;
171
+ outline: 0;
172
+ }
173
+
174
+ .select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple {
175
+ background-color: var(--darkened-bg);
176
+ cursor: default;
177
+ }
178
+
179
+ .select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove {
180
+ display: none;
181
+ }
182
+
183
+ .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple {
184
+ border-top-left-radius: 0;
185
+ border-top-right-radius: 0;
186
+ }
187
+
188
+ .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple {
189
+ border-bottom-left-radius: 0;
190
+ border-bottom-right-radius: 0;
191
+ }
192
+
193
+ .select2-container--admin-autocomplete .select2-search--dropdown {
194
+ background: var(--darkened-bg);
195
+ }
196
+
197
+ .select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field {
198
+ background: var(--body-bg);
199
+ color: var(--body-fg);
200
+ border: 1px solid var(--border-color);
201
+ border-radius: 4px;
202
+ }
203
+
204
+ .select2-container--admin-autocomplete .select2-search--inline .select2-search__field {
205
+ background: transparent;
206
+ color: var(--body-fg);
207
+ border: none;
208
+ outline: 0;
209
+ box-shadow: none;
210
+ -webkit-appearance: textfield;
211
+ }
212
+
213
+ .select2-container--admin-autocomplete .select2-results > .select2-results__options {
214
+ max-height: 200px;
215
+ overflow-y: auto;
216
+ color: var(--body-fg);
217
+ background: var(--body-bg);
218
+ }
219
+
220
+ .select2-container--admin-autocomplete .select2-results__option[role=group] {
221
+ padding: 0;
222
+ }
223
+
224
+ .select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] {
225
+ color: var(--body-quiet-color);
226
+ }
227
+
228
+ .select2-container--admin-autocomplete .select2-results__option[aria-selected=true] {
229
+ background-color: var(--selected-bg);
230
+ color: var(--body-fg);
231
+ }
232
+
233
+ .select2-container--admin-autocomplete .select2-results__option .select2-results__option {
234
+ padding-left: 1em;
235
+ }
236
+
237
+ .select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group {
238
+ padding-left: 0;
239
+ }
240
+
241
+ .select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option {
242
+ margin-left: -1em;
243
+ padding-left: 2em;
244
+ }
245
+
246
+ .select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
247
+ margin-left: -2em;
248
+ padding-left: 3em;
249
+ }
250
+
251
+ .select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
252
+ margin-left: -3em;
253
+ padding-left: 4em;
254
+ }
255
+
256
+ .select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
257
+ margin-left: -4em;
258
+ padding-left: 5em;
259
+ }
260
+
261
+ .select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
262
+ margin-left: -5em;
263
+ padding-left: 6em;
264
+ }
265
+
266
+ .select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] {
267
+ background-color: var(--primary);
268
+ color: var(--primary-fg);
269
+ }
270
+
271
+ .select2-container--admin-autocomplete .select2-results__group {
272
+ cursor: default;
273
+ display: block;
274
+ padding: 6px;
275
+ }
276
+
277
+ .errors .select2-selection {
278
+ border: 1px solid var(--error-fg);
279
+ }
static/admin/css/base.css ADDED
@@ -0,0 +1,1180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ DJANGO Admin styles
3
+ */
4
+
5
+ /* VARIABLE DEFINITIONS */
6
+ html[data-theme="light"],
7
+ :root {
8
+ --primary: #79aec8;
9
+ --secondary: #417690;
10
+ --accent: #f5dd5d;
11
+ --primary-fg: #fff;
12
+
13
+ --body-fg: #333;
14
+ --body-bg: #fff;
15
+ --body-quiet-color: #666;
16
+ --body-medium-color: #444;
17
+ --body-loud-color: #000;
18
+
19
+ --header-color: #ffc;
20
+ --header-branding-color: var(--accent);
21
+ --header-bg: var(--secondary);
22
+ --header-link-color: var(--primary-fg);
23
+
24
+ --breadcrumbs-fg: #c4dce8;
25
+ --breadcrumbs-link-fg: var(--body-bg);
26
+ --breadcrumbs-bg: #264b5d;
27
+
28
+ --link-fg: #417893;
29
+ --link-hover-color: #036;
30
+ --link-selected-fg: var(--secondary);
31
+
32
+ --hairline-color: #e8e8e8;
33
+ --border-color: #ccc;
34
+
35
+ --error-fg: #ba2121;
36
+
37
+ --message-success-bg: #dfd;
38
+ --message-warning-bg: #ffc;
39
+ --message-error-bg: #ffefef;
40
+
41
+ --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */
42
+ --selected-bg: #e4e4e4; /* E.g. selected table cells */
43
+ --selected-row: #ffc;
44
+
45
+ --button-fg: #fff;
46
+ --button-bg: var(--secondary);
47
+ --button-hover-bg: #205067;
48
+ --default-button-bg: #205067;
49
+ --default-button-hover-bg: var(--secondary);
50
+ --close-button-bg: #747474;
51
+ --close-button-hover-bg: #333;
52
+ --delete-button-bg: #ba2121;
53
+ --delete-button-hover-bg: #a41515;
54
+
55
+ --object-tools-fg: var(--button-fg);
56
+ --object-tools-bg: var(--close-button-bg);
57
+ --object-tools-hover-bg: var(--close-button-hover-bg);
58
+
59
+ --font-family-primary:
60
+ "Segoe UI",
61
+ system-ui,
62
+ Roboto,
63
+ "Helvetica Neue",
64
+ Arial,
65
+ sans-serif,
66
+ "Apple Color Emoji",
67
+ "Segoe UI Emoji",
68
+ "Segoe UI Symbol",
69
+ "Noto Color Emoji";
70
+ --font-family-monospace:
71
+ ui-monospace,
72
+ Menlo,
73
+ Monaco,
74
+ "Cascadia Mono",
75
+ "Segoe UI Mono",
76
+ "Roboto Mono",
77
+ "Oxygen Mono",
78
+ "Ubuntu Monospace",
79
+ "Source Code Pro",
80
+ "Fira Mono",
81
+ "Droid Sans Mono",
82
+ "Courier New",
83
+ monospace,
84
+ "Apple Color Emoji",
85
+ "Segoe UI Emoji",
86
+ "Segoe UI Symbol",
87
+ "Noto Color Emoji";
88
+
89
+ color-scheme: light;
90
+ }
91
+
92
+ html, body {
93
+ height: 100%;
94
+ }
95
+
96
+ body {
97
+ margin: 0;
98
+ padding: 0;
99
+ font-size: 0.875rem;
100
+ font-family: var(--font-family-primary);
101
+ color: var(--body-fg);
102
+ background: var(--body-bg);
103
+ }
104
+
105
+ /* LINKS */
106
+
107
+ a:link, a:visited {
108
+ color: var(--link-fg);
109
+ text-decoration: none;
110
+ transition: color 0.15s, background 0.15s;
111
+ }
112
+
113
+ a:focus, a:hover {
114
+ color: var(--link-hover-color);
115
+ }
116
+
117
+ a:focus {
118
+ text-decoration: underline;
119
+ }
120
+
121
+ a img {
122
+ border: none;
123
+ }
124
+
125
+ a.section:link, a.section:visited {
126
+ color: var(--header-link-color);
127
+ text-decoration: none;
128
+ }
129
+
130
+ a.section:focus, a.section:hover {
131
+ text-decoration: underline;
132
+ }
133
+
134
+ /* GLOBAL DEFAULTS */
135
+
136
+ p, ol, ul, dl {
137
+ margin: .2em 0 .8em 0;
138
+ }
139
+
140
+ p {
141
+ padding: 0;
142
+ line-height: 140%;
143
+ }
144
+
145
+ h1,h2,h3,h4,h5 {
146
+ font-weight: bold;
147
+ }
148
+
149
+ h1 {
150
+ margin: 0 0 20px;
151
+ font-weight: 300;
152
+ font-size: 1.25rem;
153
+ }
154
+
155
+ h2 {
156
+ font-size: 1rem;
157
+ margin: 1em 0 .5em 0;
158
+ }
159
+
160
+ h2.subhead {
161
+ font-weight: normal;
162
+ margin-top: 0;
163
+ }
164
+
165
+ h3 {
166
+ font-size: 0.875rem;
167
+ margin: .8em 0 .3em 0;
168
+ color: var(--body-medium-color);
169
+ font-weight: bold;
170
+ }
171
+
172
+ h4 {
173
+ font-size: 0.75rem;
174
+ margin: 1em 0 .8em 0;
175
+ padding-bottom: 3px;
176
+ color: var(--body-medium-color);
177
+ }
178
+
179
+ h5 {
180
+ font-size: 0.625rem;
181
+ margin: 1.5em 0 .5em 0;
182
+ color: var(--body-quiet-color);
183
+ text-transform: uppercase;
184
+ letter-spacing: 1px;
185
+ }
186
+
187
+ ul > li {
188
+ list-style-type: square;
189
+ padding: 1px 0;
190
+ }
191
+
192
+ li ul {
193
+ margin-bottom: 0;
194
+ }
195
+
196
+ li, dt, dd {
197
+ font-size: 0.8125rem;
198
+ line-height: 1.25rem;
199
+ }
200
+
201
+ dt {
202
+ font-weight: bold;
203
+ margin-top: 4px;
204
+ }
205
+
206
+ dd {
207
+ margin-left: 0;
208
+ }
209
+
210
+ form {
211
+ margin: 0;
212
+ padding: 0;
213
+ }
214
+
215
+ fieldset {
216
+ margin: 0;
217
+ min-width: 0;
218
+ padding: 0;
219
+ border: none;
220
+ border-top: 1px solid var(--hairline-color);
221
+ }
222
+
223
+ details summary {
224
+ cursor: pointer;
225
+ }
226
+
227
+ blockquote {
228
+ font-size: 0.6875rem;
229
+ color: #777;
230
+ margin-left: 2px;
231
+ padding-left: 10px;
232
+ border-left: 5px solid #ddd;
233
+ }
234
+
235
+ code, pre {
236
+ font-family: var(--font-family-monospace);
237
+ color: var(--body-quiet-color);
238
+ font-size: 0.75rem;
239
+ overflow-x: auto;
240
+ }
241
+
242
+ pre.literal-block {
243
+ margin: 10px;
244
+ background: var(--darkened-bg);
245
+ padding: 6px 8px;
246
+ }
247
+
248
+ code strong {
249
+ color: #930;
250
+ }
251
+
252
+ hr {
253
+ clear: both;
254
+ color: var(--hairline-color);
255
+ background-color: var(--hairline-color);
256
+ height: 1px;
257
+ border: none;
258
+ margin: 0;
259
+ padding: 0;
260
+ line-height: 1px;
261
+ }
262
+
263
+ /* TEXT STYLES & MODIFIERS */
264
+
265
+ .small {
266
+ font-size: 0.6875rem;
267
+ }
268
+
269
+ .mini {
270
+ font-size: 0.625rem;
271
+ }
272
+
273
+ .help, p.help, form p.help, div.help, form div.help, div.help li {
274
+ font-size: 0.6875rem;
275
+ color: var(--body-quiet-color);
276
+ }
277
+
278
+ div.help ul {
279
+ margin-bottom: 0;
280
+ }
281
+
282
+ .help-tooltip {
283
+ cursor: help;
284
+ }
285
+
286
+ p img, h1 img, h2 img, h3 img, h4 img, td img {
287
+ vertical-align: middle;
288
+ }
289
+
290
+ .quiet, a.quiet:link, a.quiet:visited {
291
+ color: var(--body-quiet-color);
292
+ font-weight: normal;
293
+ }
294
+
295
+ .clear {
296
+ clear: both;
297
+ }
298
+
299
+ .nowrap {
300
+ white-space: nowrap;
301
+ }
302
+
303
+ .hidden {
304
+ display: none !important;
305
+ }
306
+
307
+ /* TABLES */
308
+
309
+ table {
310
+ border-collapse: collapse;
311
+ border-color: var(--border-color);
312
+ }
313
+
314
+ td, th {
315
+ font-size: 0.8125rem;
316
+ line-height: 1rem;
317
+ border-bottom: 1px solid var(--hairline-color);
318
+ vertical-align: top;
319
+ padding: 8px;
320
+ }
321
+
322
+ th {
323
+ font-weight: 500;
324
+ text-align: left;
325
+ }
326
+
327
+ thead th,
328
+ tfoot td {
329
+ color: var(--body-quiet-color);
330
+ padding: 5px 10px;
331
+ font-size: 0.6875rem;
332
+ background: var(--body-bg);
333
+ border: none;
334
+ border-top: 1px solid var(--hairline-color);
335
+ border-bottom: 1px solid var(--hairline-color);
336
+ }
337
+
338
+ tfoot td {
339
+ border-bottom: none;
340
+ border-top: 1px solid var(--hairline-color);
341
+ }
342
+
343
+ thead th.required {
344
+ font-weight: bold;
345
+ }
346
+
347
+ tr.alt {
348
+ background: var(--darkened-bg);
349
+ }
350
+
351
+ tr:nth-child(odd), .row-form-errors {
352
+ background: var(--body-bg);
353
+ }
354
+
355
+ tr:nth-child(even),
356
+ tr:nth-child(even) .errorlist,
357
+ tr:nth-child(odd) + .row-form-errors,
358
+ tr:nth-child(odd) + .row-form-errors .errorlist {
359
+ background: var(--darkened-bg);
360
+ }
361
+
362
+ /* SORTABLE TABLES */
363
+
364
+ thead th {
365
+ padding: 5px 10px;
366
+ line-height: normal;
367
+ text-transform: uppercase;
368
+ background: var(--darkened-bg);
369
+ }
370
+
371
+ thead th a:link, thead th a:visited {
372
+ color: var(--body-quiet-color);
373
+ }
374
+
375
+ thead th.sorted {
376
+ background: var(--selected-bg);
377
+ }
378
+
379
+ thead th.sorted .text {
380
+ padding-right: 42px;
381
+ }
382
+
383
+ table thead th .text span {
384
+ padding: 8px 10px;
385
+ display: block;
386
+ }
387
+
388
+ table thead th .text a {
389
+ display: block;
390
+ cursor: pointer;
391
+ padding: 8px 10px;
392
+ }
393
+
394
+ table thead th .text a:focus, table thead th .text a:hover {
395
+ background: var(--selected-bg);
396
+ }
397
+
398
+ thead th.sorted a.sortremove {
399
+ visibility: hidden;
400
+ }
401
+
402
+ table thead th.sorted:hover a.sortremove {
403
+ visibility: visible;
404
+ }
405
+
406
+ table thead th.sorted .sortoptions {
407
+ display: block;
408
+ padding: 9px 5px 0 5px;
409
+ float: right;
410
+ text-align: right;
411
+ }
412
+
413
+ table thead th.sorted .sortpriority {
414
+ font-size: .8em;
415
+ min-width: 12px;
416
+ text-align: center;
417
+ vertical-align: 3px;
418
+ margin-left: 2px;
419
+ margin-right: 2px;
420
+ }
421
+
422
+ table thead th.sorted .sortoptions a {
423
+ position: relative;
424
+ width: 14px;
425
+ height: 14px;
426
+ display: inline-block;
427
+ background: url(../img/sorting-icons.svg) 0 0 no-repeat;
428
+ background-size: 14px auto;
429
+ }
430
+
431
+ table thead th.sorted .sortoptions a.sortremove {
432
+ background-position: 0 0;
433
+ }
434
+
435
+ table thead th.sorted .sortoptions a.sortremove:after {
436
+ content: '\\';
437
+ position: absolute;
438
+ top: -6px;
439
+ left: 3px;
440
+ font-weight: 200;
441
+ font-size: 1.125rem;
442
+ color: var(--body-quiet-color);
443
+ }
444
+
445
+ table thead th.sorted .sortoptions a.sortremove:focus:after,
446
+ table thead th.sorted .sortoptions a.sortremove:hover:after {
447
+ color: var(--link-fg);
448
+ }
449
+
450
+ table thead th.sorted .sortoptions a.sortremove:focus,
451
+ table thead th.sorted .sortoptions a.sortremove:hover {
452
+ background-position: 0 -14px;
453
+ }
454
+
455
+ table thead th.sorted .sortoptions a.ascending {
456
+ background-position: 0 -28px;
457
+ }
458
+
459
+ table thead th.sorted .sortoptions a.ascending:focus,
460
+ table thead th.sorted .sortoptions a.ascending:hover {
461
+ background-position: 0 -42px;
462
+ }
463
+
464
+ table thead th.sorted .sortoptions a.descending {
465
+ top: 1px;
466
+ background-position: 0 -56px;
467
+ }
468
+
469
+ table thead th.sorted .sortoptions a.descending:focus,
470
+ table thead th.sorted .sortoptions a.descending:hover {
471
+ background-position: 0 -70px;
472
+ }
473
+
474
+ /* FORM DEFAULTS */
475
+
476
+ input, textarea, select, .form-row p, form .button {
477
+ margin: 2px 0;
478
+ padding: 2px 3px;
479
+ vertical-align: middle;
480
+ font-family: var(--font-family-primary);
481
+ font-weight: normal;
482
+ font-size: 0.8125rem;
483
+ }
484
+ .form-row div.help {
485
+ padding: 2px 3px;
486
+ }
487
+
488
+ textarea {
489
+ vertical-align: top;
490
+ }
491
+
492
+ /*
493
+ Minifiers remove the default (text) "type" attribute from "input" HTML tags.
494
+ Add input:not([type]) to make the CSS stylesheet work the same.
495
+ */
496
+ input:not([type]), input[type=text], input[type=password], input[type=email],
497
+ input[type=url], input[type=number], input[type=tel], textarea, select,
498
+ .vTextField {
499
+ border: 1px solid var(--border-color);
500
+ border-radius: 4px;
501
+ padding: 5px 6px;
502
+ margin-top: 0;
503
+ color: var(--body-fg);
504
+ background-color: var(--body-bg);
505
+ }
506
+
507
+ /*
508
+ Minifiers remove the default (text) "type" attribute from "input" HTML tags.
509
+ Add input:not([type]) to make the CSS stylesheet work the same.
510
+ */
511
+ input:not([type]):focus, input[type=text]:focus, input[type=password]:focus,
512
+ input[type=email]:focus, input[type=url]:focus, input[type=number]:focus,
513
+ input[type=tel]:focus, textarea:focus, select:focus, .vTextField:focus {
514
+ border-color: var(--body-quiet-color);
515
+ }
516
+
517
+ select {
518
+ height: 1.875rem;
519
+ }
520
+
521
+ select[multiple] {
522
+ /* Allow HTML size attribute to override the height in the rule above. */
523
+ height: auto;
524
+ min-height: 150px;
525
+ }
526
+
527
+ /* FORM BUTTONS */
528
+
529
+ .button, input[type=submit], input[type=button], .submit-row input, a.button {
530
+ background: var(--button-bg);
531
+ padding: 10px 15px;
532
+ border: none;
533
+ border-radius: 4px;
534
+ color: var(--button-fg);
535
+ cursor: pointer;
536
+ transition: background 0.15s;
537
+ }
538
+
539
+ a.button {
540
+ padding: 4px 5px;
541
+ }
542
+
543
+ .button:active, input[type=submit]:active, input[type=button]:active,
544
+ .button:focus, input[type=submit]:focus, input[type=button]:focus,
545
+ .button:hover, input[type=submit]:hover, input[type=button]:hover {
546
+ background: var(--button-hover-bg);
547
+ }
548
+
549
+ .button[disabled], input[type=submit][disabled], input[type=button][disabled] {
550
+ opacity: 0.4;
551
+ }
552
+
553
+ .button.default, input[type=submit].default, .submit-row input.default {
554
+ border: none;
555
+ font-weight: 400;
556
+ background: var(--default-button-bg);
557
+ }
558
+
559
+ .button.default:active, input[type=submit].default:active,
560
+ .button.default:focus, input[type=submit].default:focus,
561
+ .button.default:hover, input[type=submit].default:hover {
562
+ background: var(--default-button-hover-bg);
563
+ }
564
+
565
+ .button[disabled].default,
566
+ input[type=submit][disabled].default,
567
+ input[type=button][disabled].default {
568
+ opacity: 0.4;
569
+ }
570
+
571
+
572
+ /* MODULES */
573
+
574
+ .module {
575
+ border: none;
576
+ margin-bottom: 30px;
577
+ background: var(--body-bg);
578
+ }
579
+
580
+ .module p, .module ul, .module h3, .module h4, .module dl, .module pre {
581
+ padding-left: 10px;
582
+ padding-right: 10px;
583
+ }
584
+
585
+ .module blockquote {
586
+ margin-left: 12px;
587
+ }
588
+
589
+ .module ul, .module ol {
590
+ margin-left: 1.5em;
591
+ }
592
+
593
+ .module h3 {
594
+ margin-top: .6em;
595
+ }
596
+
597
+ .module h2, .module caption, .inline-group h2 {
598
+ margin: 0;
599
+ padding: 8px;
600
+ font-weight: 400;
601
+ font-size: 0.8125rem;
602
+ text-align: left;
603
+ background: var(--header-bg);
604
+ color: var(--header-link-color);
605
+ }
606
+
607
+ .module caption,
608
+ .inline-group h2 {
609
+ font-size: 0.75rem;
610
+ letter-spacing: 0.5px;
611
+ text-transform: uppercase;
612
+ }
613
+
614
+ .module table {
615
+ border-collapse: collapse;
616
+ }
617
+
618
+ /* MESSAGES & ERRORS */
619
+
620
+ ul.messagelist {
621
+ padding: 0;
622
+ margin: 0;
623
+ }
624
+
625
+ ul.messagelist li {
626
+ display: block;
627
+ font-weight: 400;
628
+ font-size: 0.8125rem;
629
+ padding: 10px 10px 10px 65px;
630
+ margin: 0 0 10px 0;
631
+ background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat;
632
+ background-size: 16px auto;
633
+ color: var(--body-fg);
634
+ word-break: break-word;
635
+ }
636
+
637
+ ul.messagelist li.warning {
638
+ background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat;
639
+ background-size: 14px auto;
640
+ }
641
+
642
+ ul.messagelist li.error {
643
+ background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat;
644
+ background-size: 16px auto;
645
+ }
646
+
647
+ .errornote {
648
+ font-size: 0.875rem;
649
+ font-weight: 700;
650
+ display: block;
651
+ padding: 10px 12px;
652
+ margin: 0 0 10px 0;
653
+ color: var(--error-fg);
654
+ border: 1px solid var(--error-fg);
655
+ border-radius: 4px;
656
+ background-color: var(--body-bg);
657
+ background-position: 5px 12px;
658
+ overflow-wrap: break-word;
659
+ }
660
+
661
+ ul.errorlist {
662
+ margin: 0 0 4px;
663
+ padding: 0;
664
+ color: var(--error-fg);
665
+ background: var(--body-bg);
666
+ }
667
+
668
+ ul.errorlist li {
669
+ font-size: 0.8125rem;
670
+ display: block;
671
+ margin-bottom: 4px;
672
+ overflow-wrap: break-word;
673
+ }
674
+
675
+ ul.errorlist li:first-child {
676
+ margin-top: 0;
677
+ }
678
+
679
+ ul.errorlist li a {
680
+ color: inherit;
681
+ text-decoration: underline;
682
+ }
683
+
684
+ td ul.errorlist {
685
+ margin: 0;
686
+ padding: 0;
687
+ }
688
+
689
+ td ul.errorlist li {
690
+ margin: 0;
691
+ }
692
+
693
+ .form-row.errors {
694
+ margin: 0;
695
+ border: none;
696
+ border-bottom: 1px solid var(--hairline-color);
697
+ background: none;
698
+ }
699
+
700
+ .form-row.errors ul.errorlist li {
701
+ padding-left: 0;
702
+ }
703
+
704
+ .errors input, .errors select, .errors textarea,
705
+ td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea {
706
+ border: 1px solid var(--error-fg);
707
+ }
708
+
709
+ .description {
710
+ font-size: 0.75rem;
711
+ padding: 5px 0 0 12px;
712
+ }
713
+
714
+ /* BREADCRUMBS */
715
+
716
+ div.breadcrumbs {
717
+ background: var(--breadcrumbs-bg);
718
+ padding: 10px 40px;
719
+ border: none;
720
+ color: var(--breadcrumbs-fg);
721
+ text-align: left;
722
+ }
723
+
724
+ div.breadcrumbs a {
725
+ color: var(--breadcrumbs-link-fg);
726
+ }
727
+
728
+ div.breadcrumbs a:focus, div.breadcrumbs a:hover {
729
+ color: var(--breadcrumbs-fg);
730
+ }
731
+
732
+ /* ACTION ICONS */
733
+
734
+ .viewlink, .inlineviewlink {
735
+ padding-left: 16px;
736
+ background: url(../img/icon-viewlink.svg) 0 1px no-repeat;
737
+ }
738
+
739
+ .hidelink {
740
+ padding-left: 16px;
741
+ background: url(../img/icon-hidelink.svg) 0 1px no-repeat;
742
+ }
743
+
744
+ .addlink {
745
+ padding-left: 16px;
746
+ background: url(../img/icon-addlink.svg) 0 1px no-repeat;
747
+ }
748
+
749
+ .changelink, .inlinechangelink {
750
+ padding-left: 16px;
751
+ background: url(../img/icon-changelink.svg) 0 1px no-repeat;
752
+ }
753
+
754
+ .deletelink {
755
+ padding-left: 16px;
756
+ background: url(../img/icon-deletelink.svg) 0 1px no-repeat;
757
+ }
758
+
759
+ a.deletelink:link, a.deletelink:visited {
760
+ color: #CC3434; /* XXX Probably unused? */
761
+ }
762
+
763
+ a.deletelink:focus, a.deletelink:hover {
764
+ color: #993333; /* XXX Probably unused? */
765
+ text-decoration: none;
766
+ }
767
+
768
+ /* OBJECT TOOLS */
769
+
770
+ .object-tools {
771
+ font-size: 0.625rem;
772
+ font-weight: bold;
773
+ padding-left: 0;
774
+ float: right;
775
+ position: relative;
776
+ margin-top: -48px;
777
+ }
778
+
779
+ .object-tools li {
780
+ display: block;
781
+ float: left;
782
+ margin-left: 5px;
783
+ height: 1rem;
784
+ }
785
+
786
+ .object-tools a {
787
+ border-radius: 15px;
788
+ }
789
+
790
+ .object-tools a:link, .object-tools a:visited {
791
+ display: block;
792
+ float: left;
793
+ padding: 3px 12px;
794
+ background: var(--object-tools-bg);
795
+ color: var(--object-tools-fg);
796
+ font-weight: 400;
797
+ font-size: 0.6875rem;
798
+ text-transform: uppercase;
799
+ letter-spacing: 0.5px;
800
+ }
801
+
802
+ .object-tools a:focus, .object-tools a:hover {
803
+ background-color: var(--object-tools-hover-bg);
804
+ }
805
+
806
+ .object-tools a:focus{
807
+ text-decoration: none;
808
+ }
809
+
810
+ .object-tools a.viewsitelink, .object-tools a.addlink {
811
+ background-repeat: no-repeat;
812
+ background-position: right 7px center;
813
+ padding-right: 26px;
814
+ }
815
+
816
+ .object-tools a.viewsitelink {
817
+ background-image: url(../img/tooltag-arrowright.svg);
818
+ }
819
+
820
+ .object-tools a.addlink {
821
+ background-image: url(../img/tooltag-add.svg);
822
+ }
823
+
824
+ /* OBJECT HISTORY */
825
+
826
+ #change-history table {
827
+ width: 100%;
828
+ }
829
+
830
+ #change-history table tbody th {
831
+ width: 16em;
832
+ }
833
+
834
+ #change-history .paginator {
835
+ color: var(--body-quiet-color);
836
+ border-bottom: 1px solid var(--hairline-color);
837
+ background: var(--body-bg);
838
+ overflow: hidden;
839
+ }
840
+
841
+ /* PAGE STRUCTURE */
842
+
843
+ #container {
844
+ position: relative;
845
+ width: 100%;
846
+ min-width: 980px;
847
+ padding: 0;
848
+ display: flex;
849
+ flex-direction: column;
850
+ height: 100%;
851
+ }
852
+
853
+ #container > .main {
854
+ display: flex;
855
+ flex: 1 0 auto;
856
+ }
857
+
858
+ .main > .content {
859
+ flex: 1 0;
860
+ max-width: 100%;
861
+ }
862
+
863
+ .skip-to-content-link {
864
+ position: absolute;
865
+ top: -999px;
866
+ margin: 5px;
867
+ padding: 5px;
868
+ background: var(--body-bg);
869
+ z-index: 1;
870
+ }
871
+
872
+ .skip-to-content-link:focus {
873
+ left: 0px;
874
+ top: 0px;
875
+ }
876
+
877
+ #content {
878
+ padding: 20px 40px;
879
+ }
880
+
881
+ .dashboard #content {
882
+ width: 600px;
883
+ }
884
+
885
+ #content-main {
886
+ float: left;
887
+ width: 100%;
888
+ }
889
+
890
+ #content-related {
891
+ float: right;
892
+ width: 260px;
893
+ position: relative;
894
+ margin-right: -300px;
895
+ }
896
+
897
+ @media (forced-colors: active) {
898
+ #content-related {
899
+ border: 1px solid;
900
+ }
901
+ }
902
+
903
+ /* COLUMN TYPES */
904
+
905
+ .colMS {
906
+ margin-right: 300px;
907
+ }
908
+
909
+ .colSM {
910
+ margin-left: 300px;
911
+ }
912
+
913
+ .colSM #content-related {
914
+ float: left;
915
+ margin-right: 0;
916
+ margin-left: -300px;
917
+ }
918
+
919
+ .colSM #content-main {
920
+ float: right;
921
+ }
922
+
923
+ .popup .colM {
924
+ width: auto;
925
+ }
926
+
927
+ /* HEADER */
928
+
929
+ #header {
930
+ width: auto;
931
+ height: auto;
932
+ display: flex;
933
+ justify-content: space-between;
934
+ align-items: center;
935
+ padding: 10px 40px;
936
+ background: var(--header-bg);
937
+ color: var(--header-color);
938
+ }
939
+
940
+ #header a:link, #header a:visited, #logout-form button {
941
+ color: var(--header-link-color);
942
+ }
943
+
944
+ #header a:focus , #header a:hover {
945
+ text-decoration: underline;
946
+ }
947
+
948
+ @media (forced-colors: active) {
949
+ #header {
950
+ border-bottom: 1px solid;
951
+ }
952
+ }
953
+
954
+ #branding {
955
+ display: flex;
956
+ }
957
+
958
+ #site-name {
959
+ padding: 0;
960
+ margin: 0;
961
+ margin-inline-end: 20px;
962
+ font-weight: 300;
963
+ font-size: 1.5rem;
964
+ color: var(--header-branding-color);
965
+ }
966
+
967
+ #site-name a:link, #site-name a:visited {
968
+ color: var(--accent);
969
+ }
970
+
971
+ #branding h2 {
972
+ padding: 0 10px;
973
+ font-size: 0.875rem;
974
+ margin: -8px 0 8px 0;
975
+ font-weight: normal;
976
+ color: var(--header-color);
977
+ }
978
+
979
+ #branding a:hover {
980
+ text-decoration: none;
981
+ }
982
+
983
+ #logout-form {
984
+ display: inline;
985
+ }
986
+
987
+ #logout-form button {
988
+ background: none;
989
+ border: 0;
990
+ cursor: pointer;
991
+ font-family: var(--font-family-primary);
992
+ }
993
+
994
+ #user-tools {
995
+ float: right;
996
+ margin: 0 0 0 20px;
997
+ text-align: right;
998
+ }
999
+
1000
+ #user-tools, #logout-form button{
1001
+ padding: 0;
1002
+ font-weight: 300;
1003
+ font-size: 0.6875rem;
1004
+ letter-spacing: 0.5px;
1005
+ text-transform: uppercase;
1006
+ }
1007
+
1008
+ #user-tools a, #logout-form button {
1009
+ border-bottom: 1px solid rgba(255, 255, 255, 0.25);
1010
+ }
1011
+
1012
+ #user-tools a:focus, #user-tools a:hover,
1013
+ #logout-form button:active, #logout-form button:hover {
1014
+ text-decoration: none;
1015
+ border-bottom: 0;
1016
+ }
1017
+
1018
+ #logout-form button:active, #logout-form button:hover {
1019
+ margin-bottom: 1px;
1020
+ }
1021
+
1022
+ /* SIDEBAR */
1023
+
1024
+ #content-related {
1025
+ background: var(--darkened-bg);
1026
+ }
1027
+
1028
+ #content-related .module {
1029
+ background: none;
1030
+ }
1031
+
1032
+ #content-related h3 {
1033
+ color: var(--body-quiet-color);
1034
+ padding: 0 16px;
1035
+ margin: 0 0 16px;
1036
+ }
1037
+
1038
+ #content-related h4 {
1039
+ font-size: 0.8125rem;
1040
+ }
1041
+
1042
+ #content-related p {
1043
+ padding-left: 16px;
1044
+ padding-right: 16px;
1045
+ }
1046
+
1047
+ #content-related .actionlist {
1048
+ padding: 0;
1049
+ margin: 16px;
1050
+ }
1051
+
1052
+ #content-related .actionlist li {
1053
+ line-height: 1.2;
1054
+ margin-bottom: 10px;
1055
+ padding-left: 18px;
1056
+ }
1057
+
1058
+ #content-related .module h2 {
1059
+ background: none;
1060
+ padding: 16px;
1061
+ margin-bottom: 16px;
1062
+ border-bottom: 1px solid var(--hairline-color);
1063
+ font-size: 1.125rem;
1064
+ color: var(--body-fg);
1065
+ }
1066
+
1067
+ .delete-confirmation form input[type="submit"] {
1068
+ background: var(--delete-button-bg);
1069
+ border-radius: 4px;
1070
+ padding: 10px 15px;
1071
+ color: var(--button-fg);
1072
+ }
1073
+
1074
+ .delete-confirmation form input[type="submit"]:active,
1075
+ .delete-confirmation form input[type="submit"]:focus,
1076
+ .delete-confirmation form input[type="submit"]:hover {
1077
+ background: var(--delete-button-hover-bg);
1078
+ }
1079
+
1080
+ .delete-confirmation form .cancel-link {
1081
+ display: inline-block;
1082
+ vertical-align: middle;
1083
+ height: 0.9375rem;
1084
+ line-height: 0.9375rem;
1085
+ border-radius: 4px;
1086
+ padding: 10px 15px;
1087
+ color: var(--button-fg);
1088
+ background: var(--close-button-bg);
1089
+ margin: 0 0 0 10px;
1090
+ }
1091
+
1092
+ .delete-confirmation form .cancel-link:active,
1093
+ .delete-confirmation form .cancel-link:focus,
1094
+ .delete-confirmation form .cancel-link:hover {
1095
+ background: var(--close-button-hover-bg);
1096
+ }
1097
+
1098
+ /* POPUP */
1099
+ .popup #content {
1100
+ padding: 20px;
1101
+ }
1102
+
1103
+ .popup #container {
1104
+ min-width: 0;
1105
+ }
1106
+
1107
+ .popup #header {
1108
+ padding: 10px 20px;
1109
+ }
1110
+
1111
+ /* PAGINATOR */
1112
+
1113
+ .paginator {
1114
+ display: flex;
1115
+ align-items: center;
1116
+ gap: 4px;
1117
+ font-size: 0.8125rem;
1118
+ padding-top: 10px;
1119
+ padding-bottom: 10px;
1120
+ line-height: 22px;
1121
+ margin: 0;
1122
+ border-top: 1px solid var(--hairline-color);
1123
+ width: 100%;
1124
+ box-sizing: border-box;
1125
+ }
1126
+
1127
+ .paginator a:link, .paginator a:visited {
1128
+ padding: 2px 6px;
1129
+ background: var(--button-bg);
1130
+ text-decoration: none;
1131
+ color: var(--button-fg);
1132
+ }
1133
+
1134
+ .paginator a.showall {
1135
+ border: none;
1136
+ background: none;
1137
+ color: var(--link-fg);
1138
+ }
1139
+
1140
+ .paginator a.showall:focus, .paginator a.showall:hover {
1141
+ background: none;
1142
+ color: var(--link-hover-color);
1143
+ }
1144
+
1145
+ .paginator .end {
1146
+ margin-right: 6px;
1147
+ }
1148
+
1149
+ .paginator .this-page {
1150
+ padding: 2px 6px;
1151
+ font-weight: bold;
1152
+ font-size: 0.8125rem;
1153
+ vertical-align: top;
1154
+ }
1155
+
1156
+ .paginator a:focus, .paginator a:hover {
1157
+ color: white;
1158
+ background: var(--link-hover-color);
1159
+ }
1160
+
1161
+ .paginator input {
1162
+ margin-left: auto;
1163
+ }
1164
+
1165
+ .base-svgs {
1166
+ display: none;
1167
+ }
1168
+
1169
+ .visually-hidden {
1170
+ position: absolute;
1171
+ width: 1px;
1172
+ height: 1px;
1173
+ padding: 0;
1174
+ overflow: hidden;
1175
+ clip: rect(0,0,0,0);
1176
+ white-space: nowrap;
1177
+ border: 0;
1178
+ color: var(--body-fg);
1179
+ background-color: var(--body-bg);
1180
+ }
static/admin/css/changelists.css ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CHANGELISTS */
2
+
3
+ #changelist {
4
+ display: flex;
5
+ align-items: flex-start;
6
+ justify-content: space-between;
7
+ }
8
+
9
+ #changelist .changelist-form-container {
10
+ flex: 1 1 auto;
11
+ min-width: 0;
12
+ }
13
+
14
+ #changelist table {
15
+ width: 100%;
16
+ }
17
+
18
+ .change-list .hiddenfields { display:none; }
19
+
20
+ .change-list .filtered table {
21
+ border-right: none;
22
+ }
23
+
24
+ .change-list .filtered {
25
+ min-height: 400px;
26
+ }
27
+
28
+ .change-list .filtered .results, .change-list .filtered .paginator,
29
+ .filtered #toolbar, .filtered div.xfull {
30
+ width: auto;
31
+ }
32
+
33
+ .change-list .filtered table tbody th {
34
+ padding-right: 1em;
35
+ }
36
+
37
+ #changelist-form .results {
38
+ overflow-x: auto;
39
+ width: 100%;
40
+ }
41
+
42
+ #changelist .toplinks {
43
+ border-bottom: 1px solid var(--hairline-color);
44
+ }
45
+
46
+ #changelist .paginator {
47
+ color: var(--body-quiet-color);
48
+ border-bottom: 1px solid var(--hairline-color);
49
+ background: var(--body-bg);
50
+ overflow: hidden;
51
+ }
52
+
53
+ /* CHANGELIST TABLES */
54
+
55
+ #changelist table thead th {
56
+ padding: 0;
57
+ white-space: nowrap;
58
+ vertical-align: middle;
59
+ }
60
+
61
+ #changelist table thead th.action-checkbox-column {
62
+ width: 1.5em;
63
+ text-align: center;
64
+ }
65
+
66
+ #changelist table tbody td.action-checkbox {
67
+ text-align: center;
68
+ }
69
+
70
+ #changelist table tfoot {
71
+ color: var(--body-quiet-color);
72
+ }
73
+
74
+ /* TOOLBAR */
75
+
76
+ #toolbar {
77
+ padding: 8px 10px;
78
+ margin-bottom: 15px;
79
+ border-top: 1px solid var(--hairline-color);
80
+ border-bottom: 1px solid var(--hairline-color);
81
+ background: var(--darkened-bg);
82
+ color: var(--body-quiet-color);
83
+ }
84
+
85
+ #toolbar form input {
86
+ border-radius: 4px;
87
+ font-size: 0.875rem;
88
+ padding: 5px;
89
+ color: var(--body-fg);
90
+ }
91
+
92
+ #toolbar #searchbar {
93
+ height: 1.1875rem;
94
+ border: 1px solid var(--border-color);
95
+ padding: 2px 5px;
96
+ margin: 0;
97
+ vertical-align: top;
98
+ font-size: 0.8125rem;
99
+ max-width: 100%;
100
+ }
101
+
102
+ #toolbar #searchbar:focus {
103
+ border-color: var(--body-quiet-color);
104
+ }
105
+
106
+ #toolbar form input[type="submit"] {
107
+ border: 1px solid var(--border-color);
108
+ font-size: 0.8125rem;
109
+ padding: 4px 8px;
110
+ margin: 0;
111
+ vertical-align: middle;
112
+ background: var(--body-bg);
113
+ box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
114
+ cursor: pointer;
115
+ color: var(--body-fg);
116
+ }
117
+
118
+ #toolbar form input[type="submit"]:focus,
119
+ #toolbar form input[type="submit"]:hover {
120
+ border-color: var(--body-quiet-color);
121
+ }
122
+
123
+ #changelist-search img {
124
+ vertical-align: middle;
125
+ margin-right: 4px;
126
+ }
127
+
128
+ #changelist-search .help {
129
+ word-break: break-word;
130
+ }
131
+
132
+ /* FILTER COLUMN */
133
+
134
+ #changelist-filter {
135
+ flex: 0 0 240px;
136
+ order: 1;
137
+ background: var(--darkened-bg);
138
+ border-left: none;
139
+ margin: 0 0 0 30px;
140
+ }
141
+
142
+ @media (forced-colors: active) {
143
+ #changelist-filter {
144
+ border: 1px solid;
145
+ }
146
+ }
147
+
148
+ #changelist-filter h2 {
149
+ font-size: 0.875rem;
150
+ text-transform: uppercase;
151
+ letter-spacing: 0.5px;
152
+ padding: 5px 15px;
153
+ margin-bottom: 12px;
154
+ border-bottom: none;
155
+ }
156
+
157
+ #changelist-filter h3,
158
+ #changelist-filter details summary {
159
+ font-weight: 400;
160
+ padding: 0 15px;
161
+ margin-bottom: 10px;
162
+ }
163
+
164
+ #changelist-filter details summary > * {
165
+ display: inline;
166
+ }
167
+
168
+ #changelist-filter details > summary {
169
+ list-style-type: none;
170
+ }
171
+
172
+ #changelist-filter details > summary::-webkit-details-marker {
173
+ display: none;
174
+ }
175
+
176
+ #changelist-filter details > summary::before {
177
+ content: '→';
178
+ font-weight: bold;
179
+ color: var(--link-hover-color);
180
+ }
181
+
182
+ #changelist-filter details[open] > summary::before {
183
+ content: '↓';
184
+ }
185
+
186
+ #changelist-filter ul {
187
+ margin: 5px 0;
188
+ padding: 0 15px 15px;
189
+ border-bottom: 1px solid var(--hairline-color);
190
+ }
191
+
192
+ #changelist-filter ul:last-child {
193
+ border-bottom: none;
194
+ }
195
+
196
+ #changelist-filter li {
197
+ list-style-type: none;
198
+ margin-left: 0;
199
+ padding-left: 0;
200
+ }
201
+
202
+ #changelist-filter a {
203
+ display: block;
204
+ color: var(--body-quiet-color);
205
+ word-break: break-word;
206
+ }
207
+
208
+ #changelist-filter li.selected {
209
+ border-left: 5px solid var(--hairline-color);
210
+ padding-left: 10px;
211
+ margin-left: -15px;
212
+ }
213
+
214
+ #changelist-filter li.selected a {
215
+ color: var(--link-selected-fg);
216
+ }
217
+
218
+ #changelist-filter a:focus, #changelist-filter a:hover,
219
+ #changelist-filter li.selected a:focus,
220
+ #changelist-filter li.selected a:hover {
221
+ color: var(--link-hover-color);
222
+ }
223
+
224
+ #changelist-filter #changelist-filter-extra-actions {
225
+ font-size: 0.8125rem;
226
+ margin-bottom: 10px;
227
+ border-bottom: 1px solid var(--hairline-color);
228
+ }
229
+
230
+ /* DATE DRILLDOWN */
231
+
232
+ .change-list .toplinks {
233
+ display: flex;
234
+ padding-bottom: 5px;
235
+ flex-wrap: wrap;
236
+ gap: 3px 17px;
237
+ font-weight: bold;
238
+ }
239
+
240
+ .change-list .toplinks a {
241
+ font-size: 0.8125rem;
242
+ }
243
+
244
+ .change-list .toplinks .date-back {
245
+ color: var(--body-quiet-color);
246
+ }
247
+
248
+ .change-list .toplinks .date-back:focus,
249
+ .change-list .toplinks .date-back:hover {
250
+ color: var(--link-hover-color);
251
+ }
252
+
253
+ /* ACTIONS */
254
+
255
+ .filtered .actions {
256
+ border-right: none;
257
+ }
258
+
259
+ #changelist table input {
260
+ margin: 0;
261
+ vertical-align: baseline;
262
+ }
263
+
264
+ /* Once the :has() pseudo-class is supported by all browsers, the tr.selected
265
+ selector and the JS adding the class can be removed. */
266
+ #changelist tbody tr.selected {
267
+ background-color: var(--selected-row);
268
+ }
269
+
270
+ #changelist tbody tr:has(.action-select:checked) {
271
+ background-color: var(--selected-row);
272
+ }
273
+
274
+ @media (forced-colors: active) {
275
+ #changelist tbody tr.selected {
276
+ background-color: SelectedItem;
277
+ }
278
+ #changelist tbody tr:has(.action-select:checked) {
279
+ background-color: SelectedItem;
280
+ }
281
+ }
282
+
283
+ #changelist .actions {
284
+ padding: 10px;
285
+ background: var(--body-bg);
286
+ border-top: none;
287
+ border-bottom: none;
288
+ line-height: 1.5rem;
289
+ color: var(--body-quiet-color);
290
+ width: 100%;
291
+ }
292
+
293
+ #changelist .actions span.all,
294
+ #changelist .actions span.action-counter,
295
+ #changelist .actions span.clear,
296
+ #changelist .actions span.question {
297
+ font-size: 0.8125rem;
298
+ margin: 0 0.5em;
299
+ }
300
+
301
+ #changelist .actions:last-child {
302
+ border-bottom: none;
303
+ }
304
+
305
+ #changelist .actions select {
306
+ vertical-align: top;
307
+ height: 1.5rem;
308
+ color: var(--body-fg);
309
+ border: 1px solid var(--border-color);
310
+ border-radius: 4px;
311
+ font-size: 0.875rem;
312
+ padding: 0 0 0 4px;
313
+ margin: 0;
314
+ margin-left: 10px;
315
+ }
316
+
317
+ #changelist .actions select:focus {
318
+ border-color: var(--body-quiet-color);
319
+ }
320
+
321
+ #changelist .actions label {
322
+ display: inline-block;
323
+ vertical-align: middle;
324
+ font-size: 0.8125rem;
325
+ }
326
+
327
+ #changelist .actions .button {
328
+ font-size: 0.8125rem;
329
+ border: 1px solid var(--border-color);
330
+ border-radius: 4px;
331
+ background: var(--body-bg);
332
+ box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
333
+ cursor: pointer;
334
+ height: 1.5rem;
335
+ line-height: 1;
336
+ padding: 4px 8px;
337
+ margin: 0;
338
+ color: var(--body-fg);
339
+ }
340
+
341
+ #changelist .actions .button:focus, #changelist .actions .button:hover {
342
+ border-color: var(--body-quiet-color);
343
+ }
static/admin/css/dark_mode.css ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @media (prefers-color-scheme: dark) {
2
+ :root {
3
+ --primary: #264b5d;
4
+ --primary-fg: #f7f7f7;
5
+
6
+ --body-fg: #eeeeee;
7
+ --body-bg: #121212;
8
+ --body-quiet-color: #d0d0d0;
9
+ --body-medium-color: #e0e0e0;
10
+ --body-loud-color: #ffffff;
11
+
12
+ --breadcrumbs-link-fg: #e0e0e0;
13
+ --breadcrumbs-bg: var(--primary);
14
+
15
+ --link-fg: #81d4fa;
16
+ --link-hover-color: #4ac1f7;
17
+ --link-selected-fg: #6f94c6;
18
+
19
+ --hairline-color: #272727;
20
+ --border-color: #353535;
21
+
22
+ --error-fg: #e35f5f;
23
+ --message-success-bg: #006b1b;
24
+ --message-warning-bg: #583305;
25
+ --message-error-bg: #570808;
26
+
27
+ --darkened-bg: #212121;
28
+ --selected-bg: #1b1b1b;
29
+ --selected-row: #00363a;
30
+
31
+ --close-button-bg: #333333;
32
+ --close-button-hover-bg: #666666;
33
+
34
+ color-scheme: dark;
35
+ }
36
+ }
37
+
38
+
39
+ html[data-theme="dark"] {
40
+ --primary: #264b5d;
41
+ --primary-fg: #f7f7f7;
42
+
43
+ --body-fg: #eeeeee;
44
+ --body-bg: #121212;
45
+ --body-quiet-color: #d0d0d0;
46
+ --body-medium-color: #e0e0e0;
47
+ --body-loud-color: #ffffff;
48
+
49
+ --breadcrumbs-link-fg: #e0e0e0;
50
+ --breadcrumbs-bg: var(--primary);
51
+
52
+ --link-fg: #81d4fa;
53
+ --link-hover-color: #4ac1f7;
54
+ --link-selected-fg: #6f94c6;
55
+
56
+ --hairline-color: #272727;
57
+ --border-color: #353535;
58
+
59
+ --error-fg: #e35f5f;
60
+ --message-success-bg: #006b1b;
61
+ --message-warning-bg: #583305;
62
+ --message-error-bg: #570808;
63
+
64
+ --darkened-bg: #212121;
65
+ --selected-bg: #1b1b1b;
66
+ --selected-row: #00363a;
67
+
68
+ --close-button-bg: #333333;
69
+ --close-button-hover-bg: #666666;
70
+
71
+ color-scheme: dark;
72
+ }
73
+
74
+ /* THEME SWITCH */
75
+ .theme-toggle {
76
+ cursor: pointer;
77
+ border: none;
78
+ padding: 0;
79
+ background: transparent;
80
+ vertical-align: middle;
81
+ margin-inline-start: 5px;
82
+ margin-top: -1px;
83
+ }
84
+
85
+ .theme-toggle svg {
86
+ vertical-align: middle;
87
+ height: 1.5rem;
88
+ width: 1.5rem;
89
+ display: none;
90
+ }
91
+
92
+ /*
93
+ Fully hide screen reader text so we only show the one matching the current
94
+ theme.
95
+ */
96
+ .theme-toggle .visually-hidden {
97
+ display: none;
98
+ }
99
+
100
+ html[data-theme="auto"] .theme-toggle .theme-label-when-auto {
101
+ display: block;
102
+ }
103
+
104
+ html[data-theme="dark"] .theme-toggle .theme-label-when-dark {
105
+ display: block;
106
+ }
107
+
108
+ html[data-theme="light"] .theme-toggle .theme-label-when-light {
109
+ display: block;
110
+ }
111
+
112
+ /* ICONS */
113
+ .theme-toggle svg.theme-icon-when-auto,
114
+ .theme-toggle svg.theme-icon-when-dark,
115
+ .theme-toggle svg.theme-icon-when-light {
116
+ fill: var(--header-link-color);
117
+ color: var(--header-bg);
118
+ }
119
+
120
+ html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto {
121
+ display: block;
122
+ }
123
+
124
+ html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark {
125
+ display: block;
126
+ }
127
+
128
+ html[data-theme="light"] .theme-toggle svg.theme-icon-when-light {
129
+ display: block;
130
+ }
static/admin/css/dashboard.css ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* DASHBOARD */
2
+ .dashboard td, .dashboard th {
3
+ word-break: break-word;
4
+ }
5
+
6
+ .dashboard .module table th {
7
+ width: 100%;
8
+ }
9
+
10
+ .dashboard .module table td {
11
+ white-space: nowrap;
12
+ }
13
+
14
+ .dashboard .module table td a {
15
+ display: block;
16
+ padding-right: .6em;
17
+ }
18
+
19
+ /* RECENT ACTIONS MODULE */
20
+
21
+ .module ul.actionlist {
22
+ margin-left: 0;
23
+ }
24
+
25
+ ul.actionlist li {
26
+ list-style-type: none;
27
+ overflow: hidden;
28
+ text-overflow: ellipsis;
29
+ }
static/admin/css/forms.css ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('widgets.css');
2
+
3
+ /* FORM ROWS */
4
+
5
+ .form-row {
6
+ overflow: hidden;
7
+ padding: 10px;
8
+ font-size: 0.8125rem;
9
+ border-bottom: 1px solid var(--hairline-color);
10
+ }
11
+
12
+ .form-row img, .form-row input {
13
+ vertical-align: middle;
14
+ }
15
+
16
+ .form-row label input[type="checkbox"] {
17
+ margin-top: 0;
18
+ vertical-align: 0;
19
+ }
20
+
21
+ form .form-row p {
22
+ padding-left: 0;
23
+ }
24
+
25
+ .flex-container {
26
+ display: flex;
27
+ }
28
+
29
+ .form-multiline {
30
+ flex-wrap: wrap;
31
+ }
32
+
33
+ .form-multiline > div {
34
+ padding-bottom: 10px;
35
+ }
36
+
37
+ /* FORM LABELS */
38
+
39
+ label {
40
+ font-weight: normal;
41
+ color: var(--body-quiet-color);
42
+ font-size: 0.8125rem;
43
+ }
44
+
45
+ .required label, label.required {
46
+ font-weight: bold;
47
+ }
48
+
49
+ /* RADIO BUTTONS */
50
+
51
+ form div.radiolist div {
52
+ padding-right: 7px;
53
+ }
54
+
55
+ form div.radiolist.inline div {
56
+ display: inline-block;
57
+ }
58
+
59
+ form div.radiolist label {
60
+ width: auto;
61
+ }
62
+
63
+ form div.radiolist input[type="radio"] {
64
+ margin: -2px 4px 0 0;
65
+ padding: 0;
66
+ }
67
+
68
+ form ul.inline {
69
+ margin-left: 0;
70
+ padding: 0;
71
+ }
72
+
73
+ form ul.inline li {
74
+ float: left;
75
+ padding-right: 7px;
76
+ }
77
+
78
+ /* FIELDSETS */
79
+
80
+ fieldset .fieldset-heading,
81
+ fieldset .inline-heading,
82
+ :not(.inline-related) .collapse summary {
83
+ border: 1px solid var(--header-bg);
84
+ margin: 0;
85
+ padding: 8px;
86
+ font-weight: 400;
87
+ font-size: 0.8125rem;
88
+ background: var(--header-bg);
89
+ color: var(--header-link-color);
90
+ }
91
+
92
+ /* ALIGNED FIELDSETS */
93
+
94
+ .aligned label {
95
+ display: block;
96
+ padding: 4px 10px 0 0;
97
+ min-width: 160px;
98
+ width: 160px;
99
+ word-wrap: break-word;
100
+ }
101
+
102
+ .aligned label:not(.vCheckboxLabel):after {
103
+ content: '';
104
+ display: inline-block;
105
+ vertical-align: middle;
106
+ }
107
+
108
+ .aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly {
109
+ padding: 6px 0;
110
+ margin-top: 0;
111
+ margin-bottom: 0;
112
+ margin-left: 0;
113
+ overflow-wrap: break-word;
114
+ }
115
+
116
+ .aligned ul label {
117
+ display: inline;
118
+ float: none;
119
+ width: auto;
120
+ }
121
+
122
+ .aligned .form-row input {
123
+ margin-bottom: 0;
124
+ }
125
+
126
+ .colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField {
127
+ width: 350px;
128
+ }
129
+
130
+ form .aligned ul {
131
+ margin-left: 160px;
132
+ padding-left: 10px;
133
+ }
134
+
135
+ form .aligned div.radiolist {
136
+ display: inline-block;
137
+ margin: 0;
138
+ padding: 0;
139
+ }
140
+
141
+ form .aligned p.help,
142
+ form .aligned div.help {
143
+ margin-top: 0;
144
+ margin-left: 160px;
145
+ padding-left: 10px;
146
+ }
147
+
148
+ form .aligned p.date div.help.timezonewarning,
149
+ form .aligned p.datetime div.help.timezonewarning,
150
+ form .aligned p.time div.help.timezonewarning {
151
+ margin-left: 0;
152
+ padding-left: 0;
153
+ font-weight: normal;
154
+ }
155
+
156
+ form .aligned p.help:last-child,
157
+ form .aligned div.help:last-child {
158
+ margin-bottom: 0;
159
+ padding-bottom: 0;
160
+ }
161
+
162
+ form .aligned input + p.help,
163
+ form .aligned textarea + p.help,
164
+ form .aligned select + p.help,
165
+ form .aligned input + div.help,
166
+ form .aligned textarea + div.help,
167
+ form .aligned select + div.help {
168
+ margin-left: 160px;
169
+ padding-left: 10px;
170
+ }
171
+
172
+ form .aligned select option:checked {
173
+ background-color: var(--selected-row);
174
+ }
175
+
176
+ form .aligned ul li {
177
+ list-style: none;
178
+ }
179
+
180
+ form .aligned table p {
181
+ margin-left: 0;
182
+ padding-left: 0;
183
+ }
184
+
185
+ .aligned .vCheckboxLabel {
186
+ padding: 1px 0 0 5px;
187
+ }
188
+
189
+ .aligned .vCheckboxLabel + p.help,
190
+ .aligned .vCheckboxLabel + div.help {
191
+ margin-top: -4px;
192
+ }
193
+
194
+ .colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField {
195
+ width: 610px;
196
+ }
197
+
198
+ fieldset .fieldBox {
199
+ margin-right: 20px;
200
+ }
201
+
202
+ /* WIDE FIELDSETS */
203
+
204
+ .wide label {
205
+ width: 200px;
206
+ }
207
+
208
+ form .wide p.help,
209
+ form .wide ul.errorlist,
210
+ form .wide div.help {
211
+ padding-left: 50px;
212
+ }
213
+
214
+ form div.help ul {
215
+ padding-left: 0;
216
+ margin-left: 0;
217
+ }
218
+
219
+ .colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField {
220
+ width: 450px;
221
+ }
222
+
223
+ /* COLLAPSIBLE FIELDSETS */
224
+
225
+ .collapse summary .fieldset-heading,
226
+ .collapse summary .inline-heading {
227
+ background: transparent;
228
+ border: none;
229
+ color: currentColor;
230
+ display: inline;
231
+ margin: 0;
232
+ padding: 0;
233
+ }
234
+
235
+ /* MONOSPACE TEXTAREAS */
236
+
237
+ fieldset.monospace textarea {
238
+ font-family: var(--font-family-monospace);
239
+ }
240
+
241
+ /* SUBMIT ROW */
242
+
243
+ .submit-row {
244
+ padding: 12px 14px 12px;
245
+ margin: 0 0 20px;
246
+ background: var(--darkened-bg);
247
+ border: 1px solid var(--hairline-color);
248
+ border-radius: 4px;
249
+ overflow: hidden;
250
+ display: flex;
251
+ gap: 10px;
252
+ flex-wrap: wrap;
253
+ }
254
+
255
+ body.popup .submit-row {
256
+ overflow: auto;
257
+ }
258
+
259
+ .submit-row input {
260
+ height: 2.1875rem;
261
+ line-height: 0.9375rem;
262
+ }
263
+
264
+ .submit-row input, .submit-row a {
265
+ margin: 0;
266
+ }
267
+
268
+ .submit-row input.default {
269
+ text-transform: uppercase;
270
+ }
271
+
272
+ .submit-row a.deletelink {
273
+ margin-left: auto;
274
+ }
275
+
276
+ .submit-row a.deletelink {
277
+ display: block;
278
+ background: var(--delete-button-bg);
279
+ border-radius: 4px;
280
+ padding: 0.625rem 0.9375rem;
281
+ height: 0.9375rem;
282
+ line-height: 0.9375rem;
283
+ color: var(--button-fg);
284
+ }
285
+
286
+ .submit-row a.closelink {
287
+ display: inline-block;
288
+ background: var(--close-button-bg);
289
+ border-radius: 4px;
290
+ padding: 10px 15px;
291
+ height: 0.9375rem;
292
+ line-height: 0.9375rem;
293
+ color: var(--button-fg);
294
+ }
295
+
296
+ .submit-row a.deletelink:focus,
297
+ .submit-row a.deletelink:hover,
298
+ .submit-row a.deletelink:active {
299
+ background: var(--delete-button-hover-bg);
300
+ text-decoration: none;
301
+ }
302
+
303
+ .submit-row a.closelink:focus,
304
+ .submit-row a.closelink:hover,
305
+ .submit-row a.closelink:active {
306
+ background: var(--close-button-hover-bg);
307
+ text-decoration: none;
308
+ }
309
+
310
+ /* CUSTOM FORM FIELDS */
311
+
312
+ .vSelectMultipleField {
313
+ vertical-align: top;
314
+ }
315
+
316
+ .vCheckboxField {
317
+ border: none;
318
+ }
319
+
320
+ .vDateField, .vTimeField {
321
+ margin-right: 2px;
322
+ margin-bottom: 4px;
323
+ }
324
+
325
+ .vDateField {
326
+ min-width: 6.85em;
327
+ }
328
+
329
+ .vTimeField {
330
+ min-width: 4.7em;
331
+ }
332
+
333
+ .vURLField {
334
+ width: 30em;
335
+ }
336
+
337
+ .vLargeTextField, .vXMLLargeTextField {
338
+ width: 48em;
339
+ }
340
+
341
+ .flatpages-flatpage #id_content {
342
+ height: 40.2em;
343
+ }
344
+
345
+ .module table .vPositiveSmallIntegerField {
346
+ width: 2.2em;
347
+ }
348
+
349
+ .vIntegerField {
350
+ width: 5em;
351
+ }
352
+
353
+ .vBigIntegerField {
354
+ width: 10em;
355
+ }
356
+
357
+ .vForeignKeyRawIdAdminField {
358
+ width: 5em;
359
+ }
360
+
361
+ .vTextField, .vUUIDField {
362
+ width: 20em;
363
+ }
364
+
365
+ /* INLINES */
366
+
367
+ .inline-group {
368
+ padding: 0;
369
+ margin: 0 0 30px;
370
+ }
371
+
372
+ .inline-group thead th {
373
+ padding: 8px 10px;
374
+ }
375
+
376
+ .inline-group .aligned label {
377
+ width: 160px;
378
+ }
379
+
380
+ .inline-related {
381
+ position: relative;
382
+ }
383
+
384
+ .inline-related h4,
385
+ .inline-related:not(.tabular) .collapse summary {
386
+ margin: 0;
387
+ color: var(--body-medium-color);
388
+ padding: 5px;
389
+ font-size: 0.8125rem;
390
+ background: var(--darkened-bg);
391
+ border: 1px solid var(--hairline-color);
392
+ border-left-color: var(--darkened-bg);
393
+ border-right-color: var(--darkened-bg);
394
+ }
395
+
396
+ .inline-related h3 span.delete {
397
+ float: right;
398
+ }
399
+
400
+ .inline-related h3 span.delete label {
401
+ margin-left: 2px;
402
+ font-size: 0.6875rem;
403
+ }
404
+
405
+ .inline-related fieldset {
406
+ margin: 0;
407
+ background: var(--body-bg);
408
+ border: none;
409
+ width: 100%;
410
+ }
411
+
412
+ .inline-group .tabular fieldset.module {
413
+ border: none;
414
+ }
415
+
416
+ .inline-related.tabular fieldset.module table {
417
+ width: 100%;
418
+ overflow-x: scroll;
419
+ }
420
+
421
+ .last-related fieldset {
422
+ border: none;
423
+ }
424
+
425
+ .inline-group .tabular tr.has_original td {
426
+ padding-top: 2em;
427
+ }
428
+
429
+ .inline-group .tabular tr td.original {
430
+ padding: 2px 0 0 0;
431
+ width: 0;
432
+ _position: relative;
433
+ }
434
+
435
+ .inline-group .tabular th.original {
436
+ width: 0px;
437
+ padding: 0;
438
+ }
439
+
440
+ .inline-group .tabular td.original p {
441
+ position: absolute;
442
+ left: 0;
443
+ height: 1.1em;
444
+ padding: 2px 9px;
445
+ overflow: hidden;
446
+ font-size: 0.5625rem;
447
+ font-weight: bold;
448
+ color: var(--body-quiet-color);
449
+ _width: 700px;
450
+ }
451
+
452
+ .inline-group div.add-row,
453
+ .inline-group .tabular tr.add-row td {
454
+ color: var(--body-quiet-color);
455
+ background: var(--darkened-bg);
456
+ padding: 8px 10px;
457
+ border-bottom: 1px solid var(--hairline-color);
458
+ }
459
+
460
+ .inline-group .tabular tr.add-row td {
461
+ padding: 8px 10px;
462
+ border-bottom: 1px solid var(--hairline-color);
463
+ }
464
+
465
+ .inline-group div.add-row a,
466
+ .inline-group .tabular tr.add-row td a {
467
+ font-size: 0.75rem;
468
+ }
469
+
470
+ .empty-form {
471
+ display: none;
472
+ }
473
+
474
+ /* RELATED FIELD ADD ONE / LOOKUP */
475
+
476
+ .related-lookup {
477
+ margin-left: 5px;
478
+ display: inline-block;
479
+ vertical-align: middle;
480
+ background-repeat: no-repeat;
481
+ background-size: 14px;
482
+ }
483
+
484
+ .related-lookup {
485
+ width: 1rem;
486
+ height: 1rem;
487
+ background-image: url(../img/search.svg);
488
+ }
489
+
490
+ form .related-widget-wrapper ul {
491
+ display: inline-block;
492
+ margin-left: 0;
493
+ padding-left: 0;
494
+ }
495
+
496
+ .clearable-file-input input {
497
+ margin-top: 0;
498
+ }
static/admin/css/login.css ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* LOGIN FORM */
2
+
3
+ .login {
4
+ background: var(--darkened-bg);
5
+ height: auto;
6
+ }
7
+
8
+ .login #header {
9
+ height: auto;
10
+ padding: 15px 16px;
11
+ justify-content: center;
12
+ }
13
+
14
+ .login #header h1 {
15
+ font-size: 1.125rem;
16
+ margin: 0;
17
+ }
18
+
19
+ .login #header h1 a {
20
+ color: var(--header-link-color);
21
+ }
22
+
23
+ .login #content {
24
+ padding: 20px;
25
+ }
26
+
27
+ .login #container {
28
+ background: var(--body-bg);
29
+ border: 1px solid var(--hairline-color);
30
+ border-radius: 4px;
31
+ overflow: hidden;
32
+ width: 28em;
33
+ min-width: 300px;
34
+ margin: 100px auto;
35
+ height: auto;
36
+ }
37
+
38
+ .login .form-row {
39
+ padding: 4px 0;
40
+ }
41
+
42
+ .login .form-row label {
43
+ display: block;
44
+ line-height: 2em;
45
+ }
46
+
47
+ .login .form-row #id_username, .login .form-row #id_password {
48
+ padding: 8px;
49
+ width: 100%;
50
+ box-sizing: border-box;
51
+ }
52
+
53
+ .login .submit-row {
54
+ padding: 1em 0 0 0;
55
+ margin: 0;
56
+ text-align: center;
57
+ }
58
+
59
+ .login .password-reset-link {
60
+ text-align: center;
61
+ }
static/admin/css/nav_sidebar.css ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .sticky {
2
+ position: sticky;
3
+ top: 0;
4
+ max-height: 100vh;
5
+ }
6
+
7
+ .toggle-nav-sidebar {
8
+ z-index: 20;
9
+ left: 0;
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ flex: 0 0 23px;
14
+ width: 23px;
15
+ border: 0;
16
+ border-right: 1px solid var(--hairline-color);
17
+ background-color: var(--body-bg);
18
+ cursor: pointer;
19
+ font-size: 1.25rem;
20
+ color: var(--link-fg);
21
+ padding: 0;
22
+ }
23
+
24
+ [dir="rtl"] .toggle-nav-sidebar {
25
+ border-left: 1px solid var(--hairline-color);
26
+ border-right: 0;
27
+ }
28
+
29
+ .toggle-nav-sidebar:hover,
30
+ .toggle-nav-sidebar:focus {
31
+ background-color: var(--darkened-bg);
32
+ }
33
+
34
+ #nav-sidebar {
35
+ z-index: 15;
36
+ flex: 0 0 275px;
37
+ left: -276px;
38
+ margin-left: -276px;
39
+ border-top: 1px solid transparent;
40
+ border-right: 1px solid var(--hairline-color);
41
+ background-color: var(--body-bg);
42
+ overflow: auto;
43
+ }
44
+
45
+ [dir="rtl"] #nav-sidebar {
46
+ border-left: 1px solid var(--hairline-color);
47
+ border-right: 0;
48
+ left: 0;
49
+ margin-left: 0;
50
+ right: -276px;
51
+ margin-right: -276px;
52
+ }
53
+
54
+ .toggle-nav-sidebar::before {
55
+ content: '\00BB';
56
+ }
57
+
58
+ .main.shifted .toggle-nav-sidebar::before {
59
+ content: '\00AB';
60
+ }
61
+
62
+ .main > #nav-sidebar {
63
+ visibility: hidden;
64
+ }
65
+
66
+ .main.shifted > #nav-sidebar {
67
+ margin-left: 0;
68
+ visibility: visible;
69
+ }
70
+
71
+ [dir="rtl"] .main.shifted > #nav-sidebar {
72
+ margin-right: 0;
73
+ }
74
+
75
+ #nav-sidebar .module th {
76
+ width: 100%;
77
+ overflow-wrap: anywhere;
78
+ }
79
+
80
+ #nav-sidebar .module th,
81
+ #nav-sidebar .module caption {
82
+ padding-left: 16px;
83
+ }
84
+
85
+ #nav-sidebar .module td {
86
+ white-space: nowrap;
87
+ }
88
+
89
+ [dir="rtl"] #nav-sidebar .module th,
90
+ [dir="rtl"] #nav-sidebar .module caption {
91
+ padding-left: 8px;
92
+ padding-right: 16px;
93
+ }
94
+
95
+ #nav-sidebar .current-app .section:link,
96
+ #nav-sidebar .current-app .section:visited {
97
+ color: var(--header-color);
98
+ font-weight: bold;
99
+ }
100
+
101
+ #nav-sidebar .current-model {
102
+ background: var(--selected-row);
103
+ }
104
+
105
+ @media (forced-colors: active) {
106
+ #nav-sidebar .current-model {
107
+ background-color: SelectedItem;
108
+ }
109
+ }
110
+
111
+ .main > #nav-sidebar + .content {
112
+ max-width: calc(100% - 23px);
113
+ }
114
+
115
+ .main.shifted > #nav-sidebar + .content {
116
+ max-width: calc(100% - 299px);
117
+ }
118
+
119
+ @media (max-width: 767px) {
120
+ #nav-sidebar, #toggle-nav-sidebar {
121
+ display: none;
122
+ }
123
+
124
+ .main > #nav-sidebar + .content,
125
+ .main.shifted > #nav-sidebar + .content {
126
+ max-width: 100%;
127
+ }
128
+ }
129
+
130
+ #nav-filter {
131
+ width: 100%;
132
+ box-sizing: border-box;
133
+ padding: 2px 5px;
134
+ margin: 5px 0;
135
+ border: 1px solid var(--border-color);
136
+ background-color: var(--darkened-bg);
137
+ color: var(--body-fg);
138
+ }
139
+
140
+ #nav-filter:focus {
141
+ border-color: var(--body-quiet-color);
142
+ }
143
+
144
+ #nav-filter.no-results {
145
+ background: var(--message-error-bg);
146
+ }
147
+
148
+ #nav-sidebar table {
149
+ width: 100%;
150
+ }
static/admin/css/responsive.css ADDED
@@ -0,0 +1,904 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Tablets */
2
+
3
+ input[type="submit"], button {
4
+ -webkit-appearance: none;
5
+ appearance: none;
6
+ }
7
+
8
+ @media (max-width: 1024px) {
9
+ /* Basic */
10
+
11
+ html {
12
+ -webkit-text-size-adjust: 100%;
13
+ }
14
+
15
+ td, th {
16
+ padding: 10px;
17
+ font-size: 0.875rem;
18
+ }
19
+
20
+ .small {
21
+ font-size: 0.75rem;
22
+ }
23
+
24
+ /* Layout */
25
+
26
+ #container {
27
+ min-width: 0;
28
+ }
29
+
30
+ #content {
31
+ padding: 15px 20px 20px;
32
+ }
33
+
34
+ div.breadcrumbs {
35
+ padding: 10px 30px;
36
+ }
37
+
38
+ /* Header */
39
+
40
+ #header {
41
+ flex-direction: column;
42
+ padding: 15px 30px;
43
+ justify-content: flex-start;
44
+ }
45
+
46
+ #site-name {
47
+ margin: 0 0 8px;
48
+ line-height: 1.2;
49
+ }
50
+
51
+ #user-tools {
52
+ margin: 0;
53
+ font-weight: 400;
54
+ line-height: 1.85;
55
+ text-align: left;
56
+ }
57
+
58
+ #user-tools a {
59
+ display: inline-block;
60
+ line-height: 1.4;
61
+ }
62
+
63
+ /* Dashboard */
64
+
65
+ .dashboard #content {
66
+ width: auto;
67
+ }
68
+
69
+ #content-related {
70
+ margin-right: -290px;
71
+ }
72
+
73
+ .colSM #content-related {
74
+ margin-left: -290px;
75
+ }
76
+
77
+ .colMS {
78
+ margin-right: 290px;
79
+ }
80
+
81
+ .colSM {
82
+ margin-left: 290px;
83
+ }
84
+
85
+ .dashboard .module table td a {
86
+ padding-right: 0;
87
+ }
88
+
89
+ td .changelink, td .addlink {
90
+ font-size: 0.8125rem;
91
+ }
92
+
93
+ /* Changelist */
94
+
95
+ #toolbar {
96
+ border: none;
97
+ padding: 15px;
98
+ }
99
+
100
+ #changelist-search > div {
101
+ display: flex;
102
+ flex-wrap: nowrap;
103
+ max-width: 480px;
104
+ }
105
+
106
+ #changelist-search label {
107
+ line-height: 1.375rem;
108
+ }
109
+
110
+ #toolbar form #searchbar {
111
+ flex: 1 0 auto;
112
+ width: 0;
113
+ height: 1.375rem;
114
+ margin: 0 10px 0 6px;
115
+ }
116
+
117
+ #toolbar form input[type=submit] {
118
+ flex: 0 1 auto;
119
+ }
120
+
121
+ #changelist-search .quiet {
122
+ width: 0;
123
+ flex: 1 0 auto;
124
+ margin: 5px 0 0 25px;
125
+ }
126
+
127
+ #changelist .actions {
128
+ display: flex;
129
+ flex-wrap: wrap;
130
+ padding: 15px 0;
131
+ }
132
+
133
+ #changelist .actions label {
134
+ display: flex;
135
+ }
136
+
137
+ #changelist .actions select {
138
+ background: var(--body-bg);
139
+ }
140
+
141
+ #changelist .actions .button {
142
+ min-width: 48px;
143
+ margin: 0 10px;
144
+ }
145
+
146
+ #changelist .actions span.all,
147
+ #changelist .actions span.clear,
148
+ #changelist .actions span.question,
149
+ #changelist .actions span.action-counter {
150
+ font-size: 0.6875rem;
151
+ margin: 0 10px 0 0;
152
+ }
153
+
154
+ #changelist-filter {
155
+ flex-basis: 200px;
156
+ }
157
+
158
+ .change-list .filtered .results,
159
+ .change-list .filtered .paginator,
160
+ .filtered #toolbar,
161
+ .filtered .actions,
162
+
163
+ #changelist .paginator {
164
+ border-top-color: var(--hairline-color); /* XXX Is this used at all? */
165
+ }
166
+
167
+ #changelist .results + .paginator {
168
+ border-top: none;
169
+ }
170
+
171
+ /* Forms */
172
+
173
+ label {
174
+ font-size: 1rem;
175
+ }
176
+
177
+ /*
178
+ Minifiers remove the default (text) "type" attribute from "input" HTML
179
+ tags. Add input:not([type]) to make the CSS stylesheet work the same.
180
+ */
181
+ .form-row input:not([type]),
182
+ .form-row input[type=text],
183
+ .form-row input[type=password],
184
+ .form-row input[type=email],
185
+ .form-row input[type=url],
186
+ .form-row input[type=tel],
187
+ .form-row input[type=number],
188
+ .form-row textarea,
189
+ .form-row select,
190
+ .form-row .vTextField {
191
+ box-sizing: border-box;
192
+ margin: 0;
193
+ padding: 6px 8px;
194
+ min-height: 2.25rem;
195
+ font-size: 1rem;
196
+ }
197
+
198
+ .form-row select {
199
+ height: 2.25rem;
200
+ }
201
+
202
+ .form-row select[multiple] {
203
+ height: auto;
204
+ min-height: 0;
205
+ }
206
+
207
+ fieldset .fieldBox + .fieldBox {
208
+ margin-top: 10px;
209
+ padding-top: 10px;
210
+ border-top: 1px solid var(--hairline-color);
211
+ }
212
+
213
+ textarea {
214
+ max-width: 100%;
215
+ max-height: 120px;
216
+ }
217
+
218
+ .aligned label {
219
+ padding-top: 6px;
220
+ }
221
+
222
+ .aligned .related-lookup,
223
+ .aligned .datetimeshortcuts,
224
+ .aligned .related-lookup + strong {
225
+ align-self: center;
226
+ margin-left: 15px;
227
+ }
228
+
229
+ form .aligned div.radiolist {
230
+ margin-left: 2px;
231
+ }
232
+
233
+ .submit-row {
234
+ padding: 8px;
235
+ }
236
+
237
+ .submit-row a.deletelink {
238
+ padding: 10px 7px;
239
+ }
240
+
241
+ .button, input[type=submit], input[type=button], .submit-row input, a.button {
242
+ padding: 7px;
243
+ }
244
+
245
+ /* Selector */
246
+
247
+ .selector {
248
+ display: flex;
249
+ width: 100%;
250
+ }
251
+
252
+ .selector .selector-filter {
253
+ display: flex;
254
+ align-items: center;
255
+ }
256
+
257
+ .selector .selector-filter input {
258
+ width: 100%;
259
+ min-height: 0;
260
+ flex: 1 1;
261
+ }
262
+
263
+ .selector-available, .selector-chosen {
264
+ width: auto;
265
+ flex: 1 1;
266
+ display: flex;
267
+ flex-direction: column;
268
+ }
269
+
270
+ .selector select {
271
+ width: 100%;
272
+ flex: 1 0 auto;
273
+ margin-bottom: 5px;
274
+ }
275
+
276
+ .selector-chooseall, .selector-clearall {
277
+ align-self: center;
278
+ }
279
+
280
+ .stacked {
281
+ flex-direction: column;
282
+ max-width: 480px;
283
+ }
284
+
285
+ .stacked > * {
286
+ flex: 0 1 auto;
287
+ }
288
+
289
+ .stacked select {
290
+ margin-bottom: 0;
291
+ }
292
+
293
+ .stacked .selector-available, .stacked .selector-chosen {
294
+ width: auto;
295
+ }
296
+
297
+ .stacked ul.selector-chooser {
298
+ padding: 0 2px;
299
+ transform: none;
300
+ }
301
+
302
+ .stacked .selector-chooser li {
303
+ padding: 3px;
304
+ }
305
+
306
+ .help-tooltip, .selector .help-icon {
307
+ display: none;
308
+ }
309
+
310
+ .datetime input {
311
+ width: 50%;
312
+ max-width: 120px;
313
+ }
314
+
315
+ .datetime span {
316
+ font-size: 0.8125rem;
317
+ }
318
+
319
+ .datetime .timezonewarning {
320
+ display: block;
321
+ font-size: 0.6875rem;
322
+ color: var(--body-quiet-color);
323
+ }
324
+
325
+ .datetimeshortcuts {
326
+ color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */
327
+ }
328
+
329
+ .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField {
330
+ width: 75%;
331
+ }
332
+
333
+ .inline-group {
334
+ overflow: auto;
335
+ }
336
+
337
+ /* Messages */
338
+
339
+ ul.messagelist li {
340
+ padding-left: 55px;
341
+ background-position: 30px 12px;
342
+ }
343
+
344
+ ul.messagelist li.error {
345
+ background-position: 30px 12px;
346
+ }
347
+
348
+ ul.messagelist li.warning {
349
+ background-position: 30px 14px;
350
+ }
351
+
352
+ /* Login */
353
+
354
+ .login #header {
355
+ padding: 15px 20px;
356
+ }
357
+
358
+ .login #site-name {
359
+ margin: 0;
360
+ }
361
+
362
+ /* GIS */
363
+
364
+ div.olMap {
365
+ max-width: calc(100vw - 30px);
366
+ max-height: 300px;
367
+ }
368
+
369
+ .olMap + .clear_features {
370
+ display: block;
371
+ margin-top: 10px;
372
+ }
373
+
374
+ /* Docs */
375
+
376
+ .module table.xfull {
377
+ width: 100%;
378
+ }
379
+
380
+ pre.literal-block {
381
+ overflow: auto;
382
+ }
383
+ }
384
+
385
+ /* Mobile */
386
+
387
+ @media (max-width: 767px) {
388
+ /* Layout */
389
+
390
+ #header, #content {
391
+ padding: 15px;
392
+ }
393
+
394
+ div.breadcrumbs {
395
+ padding: 10px 15px;
396
+ }
397
+
398
+ /* Dashboard */
399
+
400
+ .colMS, .colSM {
401
+ margin: 0;
402
+ }
403
+
404
+ #content-related, .colSM #content-related {
405
+ width: 100%;
406
+ margin: 0;
407
+ }
408
+
409
+ #content-related .module {
410
+ margin-bottom: 0;
411
+ }
412
+
413
+ #content-related .module h2 {
414
+ padding: 10px 15px;
415
+ font-size: 1rem;
416
+ }
417
+
418
+ /* Changelist */
419
+
420
+ #changelist {
421
+ align-items: stretch;
422
+ flex-direction: column;
423
+ }
424
+
425
+ #toolbar {
426
+ padding: 10px;
427
+ }
428
+
429
+ #changelist-filter {
430
+ margin-left: 0;
431
+ }
432
+
433
+ #changelist .actions label {
434
+ flex: 1 1;
435
+ }
436
+
437
+ #changelist .actions select {
438
+ flex: 1 0;
439
+ width: 100%;
440
+ }
441
+
442
+ #changelist .actions span {
443
+ flex: 1 0 100%;
444
+ }
445
+
446
+ #changelist-filter {
447
+ position: static;
448
+ width: auto;
449
+ margin-top: 30px;
450
+ }
451
+
452
+ .object-tools {
453
+ float: none;
454
+ margin: 0 0 15px;
455
+ padding: 0;
456
+ overflow: hidden;
457
+ }
458
+
459
+ .object-tools li {
460
+ height: auto;
461
+ margin-left: 0;
462
+ }
463
+
464
+ .object-tools li + li {
465
+ margin-left: 15px;
466
+ }
467
+
468
+ /* Forms */
469
+
470
+ .form-row {
471
+ padding: 15px 0;
472
+ }
473
+
474
+ .aligned .form-row,
475
+ .aligned .form-row > div {
476
+ max-width: 100vw;
477
+ }
478
+
479
+ .aligned .form-row > div {
480
+ width: calc(100vw - 30px);
481
+ }
482
+
483
+ .flex-container {
484
+ flex-flow: column;
485
+ }
486
+
487
+ .flex-container.checkbox-row {
488
+ flex-flow: row;
489
+ }
490
+
491
+ textarea {
492
+ max-width: none;
493
+ }
494
+
495
+ .vURLField {
496
+ width: auto;
497
+ }
498
+
499
+ fieldset .fieldBox + .fieldBox {
500
+ margin-top: 15px;
501
+ padding-top: 15px;
502
+ }
503
+
504
+ .aligned label {
505
+ width: 100%;
506
+ min-width: auto;
507
+ padding: 0 0 10px;
508
+ }
509
+
510
+ .aligned label:after {
511
+ max-height: 0;
512
+ }
513
+
514
+ .aligned .form-row input,
515
+ .aligned .form-row select,
516
+ .aligned .form-row textarea {
517
+ flex: 1 1 auto;
518
+ max-width: 100%;
519
+ }
520
+
521
+ .aligned .checkbox-row input {
522
+ flex: 0 1 auto;
523
+ margin: 0;
524
+ }
525
+
526
+ .aligned .vCheckboxLabel {
527
+ flex: 1 0;
528
+ padding: 1px 0 0 5px;
529
+ }
530
+
531
+ .aligned label + p,
532
+ .aligned label + div.help,
533
+ .aligned label + div.readonly {
534
+ padding: 0;
535
+ margin-left: 0;
536
+ }
537
+
538
+ .aligned p.file-upload {
539
+ font-size: 0.8125rem;
540
+ }
541
+
542
+ span.clearable-file-input {
543
+ margin-left: 15px;
544
+ }
545
+
546
+ span.clearable-file-input label {
547
+ font-size: 0.8125rem;
548
+ padding-bottom: 0;
549
+ }
550
+
551
+ .aligned .timezonewarning {
552
+ flex: 1 0 100%;
553
+ margin-top: 5px;
554
+ }
555
+
556
+ form .aligned .form-row div.help {
557
+ width: 100%;
558
+ margin: 5px 0 0;
559
+ padding: 0;
560
+ }
561
+
562
+ form .aligned ul,
563
+ form .aligned ul.errorlist {
564
+ margin-left: 0;
565
+ padding-left: 0;
566
+ }
567
+
568
+ form .aligned div.radiolist {
569
+ margin-top: 5px;
570
+ margin-right: 15px;
571
+ margin-bottom: -3px;
572
+ }
573
+
574
+ form .aligned div.radiolist:not(.inline) div + div {
575
+ margin-top: 5px;
576
+ }
577
+
578
+ /* Related widget */
579
+
580
+ .related-widget-wrapper {
581
+ width: 100%;
582
+ display: flex;
583
+ align-items: flex-start;
584
+ }
585
+
586
+ .related-widget-wrapper .selector {
587
+ order: 1;
588
+ flex: 1 0 auto;
589
+ }
590
+
591
+ .related-widget-wrapper > a {
592
+ order: 2;
593
+ }
594
+
595
+ .related-widget-wrapper .radiolist ~ a {
596
+ align-self: flex-end;
597
+ }
598
+
599
+ .related-widget-wrapper > select ~ a {
600
+ align-self: center;
601
+ }
602
+
603
+ /* Selector */
604
+
605
+ .selector {
606
+ flex-direction: column;
607
+ gap: 10px 0;
608
+ }
609
+
610
+ .selector-available, .selector-chosen {
611
+ flex: 1 1 auto;
612
+ }
613
+
614
+ .selector select {
615
+ max-height: 96px;
616
+ }
617
+
618
+ .selector ul.selector-chooser {
619
+ display: flex;
620
+ width: 60px;
621
+ height: 30px;
622
+ padding: 0 2px;
623
+ transform: none;
624
+ }
625
+
626
+ .selector ul.selector-chooser li {
627
+ float: left;
628
+ }
629
+
630
+ .selector-remove {
631
+ background-position: 0 0;
632
+ }
633
+
634
+ :enabled.selector-remove:focus, :enabled.selector-remove:hover {
635
+ background-position: 0 -24px;
636
+ }
637
+
638
+ .selector-add {
639
+ background-position: 0 -48px;
640
+ }
641
+
642
+ :enabled.selector-add:focus, :enabled.selector-add:hover {
643
+ background-position: 0 -72px;
644
+ }
645
+
646
+ /* Inlines */
647
+
648
+ .inline-group[data-inline-type="stacked"] .inline-related {
649
+ border: 1px solid var(--hairline-color);
650
+ border-radius: 4px;
651
+ margin-top: 15px;
652
+ overflow: auto;
653
+ }
654
+
655
+ .inline-group[data-inline-type="stacked"] .inline-related > * {
656
+ box-sizing: border-box;
657
+ }
658
+
659
+ .inline-group[data-inline-type="stacked"] .inline-related .module {
660
+ padding: 0 10px;
661
+ }
662
+
663
+ .inline-group[data-inline-type="stacked"] .inline-related .module .form-row {
664
+ border-top: 1px solid var(--hairline-color);
665
+ border-bottom: none;
666
+ }
667
+
668
+ .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child {
669
+ border-top: none;
670
+ }
671
+
672
+ .inline-group[data-inline-type="stacked"] .inline-related h3 {
673
+ padding: 10px;
674
+ border-top-width: 0;
675
+ border-bottom-width: 2px;
676
+ display: flex;
677
+ flex-wrap: wrap;
678
+ align-items: center;
679
+ }
680
+
681
+ .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label {
682
+ margin-right: auto;
683
+ }
684
+
685
+ .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete {
686
+ float: none;
687
+ flex: 1 1 100%;
688
+ margin-top: 5px;
689
+ }
690
+
691
+ .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) {
692
+ width: 100%;
693
+ }
694
+
695
+ .inline-group[data-inline-type="stacked"] .aligned label {
696
+ width: 100%;
697
+ }
698
+
699
+ .inline-group[data-inline-type="stacked"] div.add-row {
700
+ margin-top: 15px;
701
+ border: 1px solid var(--hairline-color);
702
+ border-radius: 4px;
703
+ }
704
+
705
+ .inline-group div.add-row,
706
+ .inline-group .tabular tr.add-row td {
707
+ padding: 0;
708
+ }
709
+
710
+ .inline-group div.add-row a,
711
+ .inline-group .tabular tr.add-row td a {
712
+ display: block;
713
+ padding: 8px 10px 8px 26px;
714
+ background-position: 8px 9px;
715
+ }
716
+
717
+ /* Submit row */
718
+
719
+ .submit-row {
720
+ padding: 10px;
721
+ margin: 0 0 15px;
722
+ flex-direction: column;
723
+ gap: 8px;
724
+ }
725
+
726
+ .submit-row input, .submit-row input.default, .submit-row a {
727
+ text-align: center;
728
+ }
729
+
730
+ .submit-row a.closelink {
731
+ padding: 10px 0;
732
+ text-align: center;
733
+ }
734
+
735
+ .submit-row a.deletelink {
736
+ margin: 0;
737
+ }
738
+
739
+ /* Messages */
740
+
741
+ ul.messagelist li {
742
+ padding-left: 40px;
743
+ background-position: 15px 12px;
744
+ }
745
+
746
+ ul.messagelist li.error {
747
+ background-position: 15px 12px;
748
+ }
749
+
750
+ ul.messagelist li.warning {
751
+ background-position: 15px 14px;
752
+ }
753
+
754
+ /* Paginator */
755
+
756
+ .paginator .this-page, .paginator a:link, .paginator a:visited {
757
+ padding: 4px 10px;
758
+ }
759
+
760
+ /* Login */
761
+
762
+ body.login {
763
+ padding: 0 15px;
764
+ }
765
+
766
+ .login #container {
767
+ width: auto;
768
+ max-width: 480px;
769
+ margin: 50px auto;
770
+ }
771
+
772
+ .login #header,
773
+ .login #content {
774
+ padding: 15px;
775
+ }
776
+
777
+ .login #content-main {
778
+ float: none;
779
+ }
780
+
781
+ .login .form-row {
782
+ padding: 0;
783
+ }
784
+
785
+ .login .form-row + .form-row {
786
+ margin-top: 15px;
787
+ }
788
+
789
+ .login .form-row label {
790
+ margin: 0 0 5px;
791
+ line-height: 1.2;
792
+ }
793
+
794
+ .login .submit-row {
795
+ padding: 15px 0 0;
796
+ }
797
+
798
+ .login br {
799
+ display: none;
800
+ }
801
+
802
+ .login .submit-row input {
803
+ margin: 0;
804
+ text-transform: uppercase;
805
+ }
806
+
807
+ .errornote {
808
+ margin: 0 0 20px;
809
+ padding: 8px 12px;
810
+ font-size: 0.8125rem;
811
+ }
812
+
813
+ /* Calendar and clock */
814
+
815
+ .calendarbox, .clockbox {
816
+ position: fixed !important;
817
+ top: 50% !important;
818
+ left: 50% !important;
819
+ transform: translate(-50%, -50%);
820
+ margin: 0;
821
+ border: none;
822
+ overflow: visible;
823
+ }
824
+
825
+ .calendarbox:before, .clockbox:before {
826
+ content: '';
827
+ position: fixed;
828
+ top: 50%;
829
+ left: 50%;
830
+ width: 100vw;
831
+ height: 100vh;
832
+ background: rgba(0, 0, 0, 0.75);
833
+ transform: translate(-50%, -50%);
834
+ }
835
+
836
+ .calendarbox > *, .clockbox > * {
837
+ position: relative;
838
+ z-index: 1;
839
+ }
840
+
841
+ .calendarbox > div:first-child {
842
+ z-index: 2;
843
+ }
844
+
845
+ .calendarbox .calendar, .clockbox h2 {
846
+ border-radius: 4px 4px 0 0;
847
+ overflow: hidden;
848
+ }
849
+
850
+ .calendarbox .calendar-cancel, .clockbox .calendar-cancel {
851
+ border-radius: 0 0 4px 4px;
852
+ overflow: hidden;
853
+ }
854
+
855
+ .calendar-shortcuts {
856
+ padding: 10px 0;
857
+ font-size: 0.75rem;
858
+ line-height: 0.75rem;
859
+ }
860
+
861
+ .calendar-shortcuts a {
862
+ margin: 0 4px;
863
+ }
864
+
865
+ .timelist a {
866
+ background: var(--body-bg);
867
+ padding: 4px;
868
+ }
869
+
870
+ .calendar-cancel {
871
+ padding: 8px 10px;
872
+ }
873
+
874
+ .clockbox h2 {
875
+ padding: 8px 15px;
876
+ }
877
+
878
+ .calendar caption {
879
+ padding: 10px;
880
+ }
881
+
882
+ .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next {
883
+ z-index: 1;
884
+ top: 10px;
885
+ }
886
+
887
+ /* History */
888
+
889
+ table#change-history tbody th, table#change-history tbody td {
890
+ font-size: 0.8125rem;
891
+ word-break: break-word;
892
+ }
893
+
894
+ table#change-history tbody th {
895
+ width: auto;
896
+ }
897
+
898
+ /* Docs */
899
+
900
+ table.model tbody th, table.model tbody td {
901
+ font-size: 0.8125rem;
902
+ word-break: break-word;
903
+ }
904
+ }
static/admin/css/responsive_rtl.css ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* TABLETS */
2
+
3
+ @media (max-width: 1024px) {
4
+ [dir="rtl"] .colMS {
5
+ margin-right: 0;
6
+ }
7
+
8
+ [dir="rtl"] #user-tools {
9
+ text-align: right;
10
+ }
11
+
12
+ [dir="rtl"] #changelist .actions label {
13
+ padding-left: 10px;
14
+ padding-right: 0;
15
+ }
16
+
17
+ [dir="rtl"] #changelist .actions select {
18
+ margin-left: 0;
19
+ margin-right: 15px;
20
+ }
21
+
22
+ [dir="rtl"] .change-list .filtered .results,
23
+ [dir="rtl"] .change-list .filtered .paginator,
24
+ [dir="rtl"] .filtered #toolbar,
25
+ [dir="rtl"] .filtered div.xfull,
26
+ [dir="rtl"] .filtered .actions,
27
+ [dir="rtl"] #changelist-filter {
28
+ margin-left: 0;
29
+ }
30
+
31
+ [dir="rtl"] .inline-group div.add-row a,
32
+ [dir="rtl"] .inline-group .tabular tr.add-row td a {
33
+ padding: 8px 26px 8px 10px;
34
+ background-position: calc(100% - 8px) 9px;
35
+ }
36
+
37
+ [dir="rtl"] .object-tools li {
38
+ float: right;
39
+ }
40
+
41
+ [dir="rtl"] .object-tools li + li {
42
+ margin-left: 0;
43
+ margin-right: 15px;
44
+ }
45
+
46
+ [dir="rtl"] .dashboard .module table td a {
47
+ padding-left: 0;
48
+ padding-right: 16px;
49
+ }
50
+ }
51
+
52
+ /* MOBILE */
53
+
54
+ @media (max-width: 767px) {
55
+ [dir="rtl"] .aligned .related-lookup,
56
+ [dir="rtl"] .aligned .datetimeshortcuts {
57
+ margin-left: 0;
58
+ margin-right: 15px;
59
+ }
60
+
61
+ [dir="rtl"] .aligned ul,
62
+ [dir="rtl"] form .aligned ul.errorlist {
63
+ margin-right: 0;
64
+ }
65
+
66
+ [dir="rtl"] #changelist-filter {
67
+ margin-left: 0;
68
+ margin-right: 0;
69
+ }
70
+ [dir="rtl"] .aligned .vCheckboxLabel {
71
+ padding: 1px 5px 0 0;
72
+ }
73
+
74
+ [dir="rtl"] .selector-remove {
75
+ background-position: 0 0;
76
+ }
77
+
78
+ [dir="rtl"] :enabled.selector-remove:focus, :enabled.selector-remove:hover {
79
+ background-position: 0 -24px;
80
+ }
81
+
82
+ [dir="rtl"] .selector-add {
83
+ background-position: 0 -48px;
84
+ }
85
+
86
+ [dir="rtl"] :enabled.selector-add:focus, :enabled.selector-add:hover {
87
+ background-position: 0 -72px;
88
+ }
89
+ }
static/admin/css/rtl.css ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* GLOBAL */
2
+
3
+ th {
4
+ text-align: right;
5
+ }
6
+
7
+ .module h2, .module caption {
8
+ text-align: right;
9
+ }
10
+
11
+ .module ul, .module ol {
12
+ margin-left: 0;
13
+ margin-right: 1.5em;
14
+ }
15
+
16
+ .viewlink, .addlink, .changelink, .hidelink {
17
+ padding-left: 0;
18
+ padding-right: 16px;
19
+ background-position: 100% 1px;
20
+ }
21
+
22
+ .deletelink {
23
+ padding-left: 0;
24
+ padding-right: 16px;
25
+ background-position: 100% 1px;
26
+ }
27
+
28
+ .object-tools {
29
+ float: left;
30
+ }
31
+
32
+ thead th:first-child,
33
+ tfoot td:first-child {
34
+ border-left: none;
35
+ }
36
+
37
+ /* LAYOUT */
38
+
39
+ #user-tools {
40
+ right: auto;
41
+ left: 0;
42
+ text-align: left;
43
+ }
44
+
45
+ div.breadcrumbs {
46
+ text-align: right;
47
+ }
48
+
49
+ #content-main {
50
+ float: right;
51
+ }
52
+
53
+ #content-related {
54
+ float: left;
55
+ margin-left: -300px;
56
+ margin-right: auto;
57
+ }
58
+
59
+ .colMS {
60
+ margin-left: 300px;
61
+ margin-right: 0;
62
+ }
63
+
64
+ /* SORTABLE TABLES */
65
+
66
+ table thead th.sorted .sortoptions {
67
+ float: left;
68
+ }
69
+
70
+ thead th.sorted .text {
71
+ padding-right: 0;
72
+ padding-left: 42px;
73
+ }
74
+
75
+ /* dashboard styles */
76
+
77
+ .dashboard .module table td a {
78
+ padding-left: .6em;
79
+ padding-right: 16px;
80
+ }
81
+
82
+ /* changelists styles */
83
+
84
+ .change-list .filtered table {
85
+ border-left: none;
86
+ border-right: 0px none;
87
+ }
88
+
89
+ #changelist-filter {
90
+ border-left: none;
91
+ border-right: none;
92
+ margin-left: 0;
93
+ margin-right: 30px;
94
+ }
95
+
96
+ #changelist-filter li.selected {
97
+ border-left: none;
98
+ padding-left: 10px;
99
+ margin-left: 0;
100
+ border-right: 5px solid var(--hairline-color);
101
+ padding-right: 10px;
102
+ margin-right: -15px;
103
+ }
104
+
105
+ #changelist table tbody td:first-child, #changelist table tbody th:first-child {
106
+ border-right: none;
107
+ border-left: none;
108
+ }
109
+
110
+ .paginator .end {
111
+ margin-left: 6px;
112
+ margin-right: 0;
113
+ }
114
+
115
+ .paginator input {
116
+ margin-left: 0;
117
+ margin-right: auto;
118
+ }
119
+
120
+ /* FORMS */
121
+
122
+ .aligned label {
123
+ padding: 0 0 3px 1em;
124
+ }
125
+
126
+ .submit-row a.deletelink {
127
+ margin-left: 0;
128
+ margin-right: auto;
129
+ }
130
+
131
+ .vDateField, .vTimeField {
132
+ margin-left: 2px;
133
+ }
134
+
135
+ .aligned .form-row input {
136
+ margin-left: 5px;
137
+ }
138
+
139
+ form .aligned ul {
140
+ margin-right: 163px;
141
+ padding-right: 10px;
142
+ margin-left: 0;
143
+ padding-left: 0;
144
+ }
145
+
146
+ form ul.inline li {
147
+ float: right;
148
+ padding-right: 0;
149
+ padding-left: 7px;
150
+ }
151
+
152
+ form .aligned p.help,
153
+ form .aligned div.help {
154
+ margin-left: 0;
155
+ margin-right: 160px;
156
+ padding-right: 10px;
157
+ }
158
+
159
+ form div.help ul,
160
+ form .aligned .checkbox-row + .help,
161
+ form .aligned p.date div.help.timezonewarning,
162
+ form .aligned p.datetime div.help.timezonewarning,
163
+ form .aligned p.time div.help.timezonewarning {
164
+ margin-right: 0;
165
+ padding-right: 0;
166
+ }
167
+
168
+ form .wide p.help,
169
+ form .wide ul.errorlist,
170
+ form .wide div.help {
171
+ padding-left: 0;
172
+ padding-right: 50px;
173
+ }
174
+
175
+ .submit-row {
176
+ text-align: right;
177
+ }
178
+
179
+ fieldset .fieldBox {
180
+ margin-left: 20px;
181
+ margin-right: 0;
182
+ }
183
+
184
+ .errorlist li {
185
+ background-position: 100% 12px;
186
+ padding: 0;
187
+ }
188
+
189
+ .errornote {
190
+ background-position: 100% 12px;
191
+ padding: 10px 12px;
192
+ }
193
+
194
+ /* WIDGETS */
195
+
196
+ .calendarnav-previous {
197
+ top: 0;
198
+ left: auto;
199
+ right: 10px;
200
+ background: url(../img/calendar-icons.svg) 0 -15px no-repeat;
201
+ }
202
+
203
+ .calendarnav-next {
204
+ top: 0;
205
+ right: auto;
206
+ left: 10px;
207
+ background: url(../img/calendar-icons.svg) 0 0 no-repeat;
208
+ }
209
+
210
+ .calendar caption, .calendarbox h2 {
211
+ text-align: center;
212
+ }
213
+
214
+ .selector {
215
+ float: right;
216
+ }
217
+
218
+ .selector .selector-filter {
219
+ text-align: right;
220
+ }
221
+
222
+ .selector-add {
223
+ background: url(../img/selector-icons.svg) 0 -96px no-repeat;
224
+ background-size: 24px auto;
225
+ }
226
+
227
+ :enabled.selector-add:focus, :enabled.selector-add:hover {
228
+ background-position: 0 -120px;
229
+ }
230
+
231
+ .selector-remove {
232
+ background: url(../img/selector-icons.svg) 0 -144px no-repeat;
233
+ background-size: 24px auto;
234
+ }
235
+
236
+ :enabled.selector-remove:focus, :enabled.selector-remove:hover {
237
+ background-position: 0 -168px;
238
+ }
239
+
240
+ .selector-chooseall {
241
+ background: url(../img/selector-icons.svg) right -128px no-repeat;
242
+ }
243
+
244
+ :enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover {
245
+ background-position: 100% -144px;
246
+ }
247
+
248
+ .selector-clearall {
249
+ background: url(../img/selector-icons.svg) 0 -160px no-repeat;
250
+ }
251
+
252
+ :enabled.selector-clearall:focus, :enabled.selector-clearall:hover {
253
+ background-position: 0 -176px;
254
+ }
255
+
256
+ .inline-deletelink {
257
+ float: left;
258
+ }
259
+
260
+ form .form-row p.datetime {
261
+ overflow: hidden;
262
+ }
263
+
264
+ .related-widget-wrapper {
265
+ float: right;
266
+ }
267
+
268
+ /* MISC */
269
+
270
+ .inline-related h2, .inline-group h2 {
271
+ text-align: right
272
+ }
273
+
274
+ .inline-related h3 span.delete {
275
+ padding-right: 20px;
276
+ padding-left: inherit;
277
+ left: 10px;
278
+ right: inherit;
279
+ float:left;
280
+ }
281
+
282
+ .inline-related h3 span.delete label {
283
+ margin-left: inherit;
284
+ margin-right: 2px;
285
+ }
286
+
287
+ .inline-group .tabular td.original p {
288
+ right: 0;
289
+ }
290
+
291
+ .selector .selector-chooser {
292
+ margin: 0;
293
+ }
static/admin/css/unusable_password_field.css ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Hide warnings fields if usable password is selected */
2
+ form:has(#id_usable_password input[value="true"]:checked) .messagelist {
3
+ display: none;
4
+ }
5
+
6
+ /* Hide password fields if unusable password is selected */
7
+ form:has(#id_usable_password input[value="false"]:checked) .field-password1,
8
+ form:has(#id_usable_password input[value="false"]:checked) .field-password2 {
9
+ display: none;
10
+ }
11
+
12
+ /* Select appropriate submit button */
13
+ form:has(#id_usable_password input[value="true"]:checked) input[type="submit"].unset-password {
14
+ display: none;
15
+ }
16
+
17
+ form:has(#id_usable_password input[value="false"]:checked) input[type="submit"].set-password {
18
+ display: none;
19
+ }
static/admin/css/vendor/select2/LICENSE-SELECT2.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
static/admin/css/vendor/select2/select2.css ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .select2-container {
2
+ box-sizing: border-box;
3
+ display: inline-block;
4
+ margin: 0;
5
+ position: relative;
6
+ vertical-align: middle; }
7
+ .select2-container .select2-selection--single {
8
+ box-sizing: border-box;
9
+ cursor: pointer;
10
+ display: block;
11
+ height: 28px;
12
+ user-select: none;
13
+ -webkit-user-select: none; }
14
+ .select2-container .select2-selection--single .select2-selection__rendered {
15
+ display: block;
16
+ padding-left: 8px;
17
+ padding-right: 20px;
18
+ overflow: hidden;
19
+ text-overflow: ellipsis;
20
+ white-space: nowrap; }
21
+ .select2-container .select2-selection--single .select2-selection__clear {
22
+ position: relative; }
23
+ .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered {
24
+ padding-right: 8px;
25
+ padding-left: 20px; }
26
+ .select2-container .select2-selection--multiple {
27
+ box-sizing: border-box;
28
+ cursor: pointer;
29
+ display: block;
30
+ min-height: 32px;
31
+ user-select: none;
32
+ -webkit-user-select: none; }
33
+ .select2-container .select2-selection--multiple .select2-selection__rendered {
34
+ display: inline-block;
35
+ overflow: hidden;
36
+ padding-left: 8px;
37
+ text-overflow: ellipsis;
38
+ white-space: nowrap; }
39
+ .select2-container .select2-search--inline {
40
+ float: left; }
41
+ .select2-container .select2-search--inline .select2-search__field {
42
+ box-sizing: border-box;
43
+ border: none;
44
+ font-size: 100%;
45
+ margin-top: 5px;
46
+ padding: 0; }
47
+ .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button {
48
+ -webkit-appearance: none; }
49
+
50
+ .select2-dropdown {
51
+ background-color: white;
52
+ border: 1px solid #aaa;
53
+ border-radius: 4px;
54
+ box-sizing: border-box;
55
+ display: block;
56
+ position: absolute;
57
+ left: -100000px;
58
+ width: 100%;
59
+ z-index: 1051; }
60
+
61
+ .select2-results {
62
+ display: block; }
63
+
64
+ .select2-results__options {
65
+ list-style: none;
66
+ margin: 0;
67
+ padding: 0; }
68
+
69
+ .select2-results__option {
70
+ padding: 6px;
71
+ user-select: none;
72
+ -webkit-user-select: none; }
73
+ .select2-results__option[aria-selected] {
74
+ cursor: pointer; }
75
+
76
+ .select2-container--open .select2-dropdown {
77
+ left: 0; }
78
+
79
+ .select2-container--open .select2-dropdown--above {
80
+ border-bottom: none;
81
+ border-bottom-left-radius: 0;
82
+ border-bottom-right-radius: 0; }
83
+
84
+ .select2-container--open .select2-dropdown--below {
85
+ border-top: none;
86
+ border-top-left-radius: 0;
87
+ border-top-right-radius: 0; }
88
+
89
+ .select2-search--dropdown {
90
+ display: block;
91
+ padding: 4px; }
92
+ .select2-search--dropdown .select2-search__field {
93
+ padding: 4px;
94
+ width: 100%;
95
+ box-sizing: border-box; }
96
+ .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button {
97
+ -webkit-appearance: none; }
98
+ .select2-search--dropdown.select2-search--hide {
99
+ display: none; }
100
+
101
+ .select2-close-mask {
102
+ border: 0;
103
+ margin: 0;
104
+ padding: 0;
105
+ display: block;
106
+ position: fixed;
107
+ left: 0;
108
+ top: 0;
109
+ min-height: 100%;
110
+ min-width: 100%;
111
+ height: auto;
112
+ width: auto;
113
+ opacity: 0;
114
+ z-index: 99;
115
+ background-color: #fff;
116
+ filter: alpha(opacity=0); }
117
+
118
+ .select2-hidden-accessible {
119
+ border: 0 !important;
120
+ clip: rect(0 0 0 0) !important;
121
+ -webkit-clip-path: inset(50%) !important;
122
+ clip-path: inset(50%) !important;
123
+ height: 1px !important;
124
+ overflow: hidden !important;
125
+ padding: 0 !important;
126
+ position: absolute !important;
127
+ width: 1px !important;
128
+ white-space: nowrap !important; }
129
+
130
+ .select2-container--default .select2-selection--single {
131
+ background-color: #fff;
132
+ border: 1px solid #aaa;
133
+ border-radius: 4px; }
134
+ .select2-container--default .select2-selection--single .select2-selection__rendered {
135
+ color: #444;
136
+ line-height: 28px; }
137
+ .select2-container--default .select2-selection--single .select2-selection__clear {
138
+ cursor: pointer;
139
+ float: right;
140
+ font-weight: bold; }
141
+ .select2-container--default .select2-selection--single .select2-selection__placeholder {
142
+ color: #999; }
143
+ .select2-container--default .select2-selection--single .select2-selection__arrow {
144
+ height: 26px;
145
+ position: absolute;
146
+ top: 1px;
147
+ right: 1px;
148
+ width: 20px; }
149
+ .select2-container--default .select2-selection--single .select2-selection__arrow b {
150
+ border-color: #888 transparent transparent transparent;
151
+ border-style: solid;
152
+ border-width: 5px 4px 0 4px;
153
+ height: 0;
154
+ left: 50%;
155
+ margin-left: -4px;
156
+ margin-top: -2px;
157
+ position: absolute;
158
+ top: 50%;
159
+ width: 0; }
160
+
161
+ .select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear {
162
+ float: left; }
163
+
164
+ .select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow {
165
+ left: 1px;
166
+ right: auto; }
167
+
168
+ .select2-container--default.select2-container--disabled .select2-selection--single {
169
+ background-color: #eee;
170
+ cursor: default; }
171
+ .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear {
172
+ display: none; }
173
+
174
+ .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b {
175
+ border-color: transparent transparent #888 transparent;
176
+ border-width: 0 4px 5px 4px; }
177
+
178
+ .select2-container--default .select2-selection--multiple {
179
+ background-color: white;
180
+ border: 1px solid #aaa;
181
+ border-radius: 4px;
182
+ cursor: text; }
183
+ .select2-container--default .select2-selection--multiple .select2-selection__rendered {
184
+ box-sizing: border-box;
185
+ list-style: none;
186
+ margin: 0;
187
+ padding: 0 5px;
188
+ width: 100%; }
189
+ .select2-container--default .select2-selection--multiple .select2-selection__rendered li {
190
+ list-style: none; }
191
+ .select2-container--default .select2-selection--multiple .select2-selection__clear {
192
+ cursor: pointer;
193
+ float: right;
194
+ font-weight: bold;
195
+ margin-top: 5px;
196
+ margin-right: 10px;
197
+ padding: 1px; }
198
+ .select2-container--default .select2-selection--multiple .select2-selection__choice {
199
+ background-color: #e4e4e4;
200
+ border: 1px solid #aaa;
201
+ border-radius: 4px;
202
+ cursor: default;
203
+ float: left;
204
+ margin-right: 5px;
205
+ margin-top: 5px;
206
+ padding: 0 5px; }
207
+ .select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
208
+ color: #999;
209
+ cursor: pointer;
210
+ display: inline-block;
211
+ font-weight: bold;
212
+ margin-right: 2px; }
213
+ .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover {
214
+ color: #333; }
215
+
216
+ .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline {
217
+ float: right; }
218
+
219
+ .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
220
+ margin-left: 5px;
221
+ margin-right: auto; }
222
+
223
+ .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
224
+ margin-left: 2px;
225
+ margin-right: auto; }
226
+
227
+ .select2-container--default.select2-container--focus .select2-selection--multiple {
228
+ border: solid black 1px;
229
+ outline: 0; }
230
+
231
+ .select2-container--default.select2-container--disabled .select2-selection--multiple {
232
+ background-color: #eee;
233
+ cursor: default; }
234
+
235
+ .select2-container--default.select2-container--disabled .select2-selection__choice__remove {
236
+ display: none; }
237
+
238
+ .select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple {
239
+ border-top-left-radius: 0;
240
+ border-top-right-radius: 0; }
241
+
242
+ .select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple {
243
+ border-bottom-left-radius: 0;
244
+ border-bottom-right-radius: 0; }
245
+
246
+ .select2-container--default .select2-search--dropdown .select2-search__field {
247
+ border: 1px solid #aaa; }
248
+
249
+ .select2-container--default .select2-search--inline .select2-search__field {
250
+ background: transparent;
251
+ border: none;
252
+ outline: 0;
253
+ box-shadow: none;
254
+ -webkit-appearance: textfield; }
255
+
256
+ .select2-container--default .select2-results > .select2-results__options {
257
+ max-height: 200px;
258
+ overflow-y: auto; }
259
+
260
+ .select2-container--default .select2-results__option[role=group] {
261
+ padding: 0; }
262
+
263
+ .select2-container--default .select2-results__option[aria-disabled=true] {
264
+ color: #999; }
265
+
266
+ .select2-container--default .select2-results__option[aria-selected=true] {
267
+ background-color: #ddd; }
268
+
269
+ .select2-container--default .select2-results__option .select2-results__option {
270
+ padding-left: 1em; }
271
+ .select2-container--default .select2-results__option .select2-results__option .select2-results__group {
272
+ padding-left: 0; }
273
+ .select2-container--default .select2-results__option .select2-results__option .select2-results__option {
274
+ margin-left: -1em;
275
+ padding-left: 2em; }
276
+ .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
277
+ margin-left: -2em;
278
+ padding-left: 3em; }
279
+ .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
280
+ margin-left: -3em;
281
+ padding-left: 4em; }
282
+ .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
283
+ margin-left: -4em;
284
+ padding-left: 5em; }
285
+ .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
286
+ margin-left: -5em;
287
+ padding-left: 6em; }
288
+
289
+ .select2-container--default .select2-results__option--highlighted[aria-selected] {
290
+ background-color: #5897fb;
291
+ color: white; }
292
+
293
+ .select2-container--default .select2-results__group {
294
+ cursor: default;
295
+ display: block;
296
+ padding: 6px; }
297
+
298
+ .select2-container--classic .select2-selection--single {
299
+ background-color: #f7f7f7;
300
+ border: 1px solid #aaa;
301
+ border-radius: 4px;
302
+ outline: 0;
303
+ background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%);
304
+ background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%);
305
+ background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%);
306
+ background-repeat: repeat-x;
307
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); }
308
+ .select2-container--classic .select2-selection--single:focus {
309
+ border: 1px solid #5897fb; }
310
+ .select2-container--classic .select2-selection--single .select2-selection__rendered {
311
+ color: #444;
312
+ line-height: 28px; }
313
+ .select2-container--classic .select2-selection--single .select2-selection__clear {
314
+ cursor: pointer;
315
+ float: right;
316
+ font-weight: bold;
317
+ margin-right: 10px; }
318
+ .select2-container--classic .select2-selection--single .select2-selection__placeholder {
319
+ color: #999; }
320
+ .select2-container--classic .select2-selection--single .select2-selection__arrow {
321
+ background-color: #ddd;
322
+ border: none;
323
+ border-left: 1px solid #aaa;
324
+ border-top-right-radius: 4px;
325
+ border-bottom-right-radius: 4px;
326
+ height: 26px;
327
+ position: absolute;
328
+ top: 1px;
329
+ right: 1px;
330
+ width: 20px;
331
+ background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%);
332
+ background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%);
333
+ background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%);
334
+ background-repeat: repeat-x;
335
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); }
336
+ .select2-container--classic .select2-selection--single .select2-selection__arrow b {
337
+ border-color: #888 transparent transparent transparent;
338
+ border-style: solid;
339
+ border-width: 5px 4px 0 4px;
340
+ height: 0;
341
+ left: 50%;
342
+ margin-left: -4px;
343
+ margin-top: -2px;
344
+ position: absolute;
345
+ top: 50%;
346
+ width: 0; }
347
+
348
+ .select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear {
349
+ float: left; }
350
+
351
+ .select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow {
352
+ border: none;
353
+ border-right: 1px solid #aaa;
354
+ border-radius: 0;
355
+ border-top-left-radius: 4px;
356
+ border-bottom-left-radius: 4px;
357
+ left: 1px;
358
+ right: auto; }
359
+
360
+ .select2-container--classic.select2-container--open .select2-selection--single {
361
+ border: 1px solid #5897fb; }
362
+ .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow {
363
+ background: transparent;
364
+ border: none; }
365
+ .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b {
366
+ border-color: transparent transparent #888 transparent;
367
+ border-width: 0 4px 5px 4px; }
368
+
369
+ .select2-container--classic.select2-container--open.select2-container--above .select2-selection--single {
370
+ border-top: none;
371
+ border-top-left-radius: 0;
372
+ border-top-right-radius: 0;
373
+ background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%);
374
+ background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%);
375
+ background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%);
376
+ background-repeat: repeat-x;
377
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); }
378
+
379
+ .select2-container--classic.select2-container--open.select2-container--below .select2-selection--single {
380
+ border-bottom: none;
381
+ border-bottom-left-radius: 0;
382
+ border-bottom-right-radius: 0;
383
+ background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%);
384
+ background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%);
385
+ background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%);
386
+ background-repeat: repeat-x;
387
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); }
388
+
389
+ .select2-container--classic .select2-selection--multiple {
390
+ background-color: white;
391
+ border: 1px solid #aaa;
392
+ border-radius: 4px;
393
+ cursor: text;
394
+ outline: 0; }
395
+ .select2-container--classic .select2-selection--multiple:focus {
396
+ border: 1px solid #5897fb; }
397
+ .select2-container--classic .select2-selection--multiple .select2-selection__rendered {
398
+ list-style: none;
399
+ margin: 0;
400
+ padding: 0 5px; }
401
+ .select2-container--classic .select2-selection--multiple .select2-selection__clear {
402
+ display: none; }
403
+ .select2-container--classic .select2-selection--multiple .select2-selection__choice {
404
+ background-color: #e4e4e4;
405
+ border: 1px solid #aaa;
406
+ border-radius: 4px;
407
+ cursor: default;
408
+ float: left;
409
+ margin-right: 5px;
410
+ margin-top: 5px;
411
+ padding: 0 5px; }
412
+ .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove {
413
+ color: #888;
414
+ cursor: pointer;
415
+ display: inline-block;
416
+ font-weight: bold;
417
+ margin-right: 2px; }
418
+ .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover {
419
+ color: #555; }
420
+
421
+ .select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
422
+ float: right;
423
+ margin-left: 5px;
424
+ margin-right: auto; }
425
+
426
+ .select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
427
+ margin-left: 2px;
428
+ margin-right: auto; }
429
+
430
+ .select2-container--classic.select2-container--open .select2-selection--multiple {
431
+ border: 1px solid #5897fb; }
432
+
433
+ .select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple {
434
+ border-top: none;
435
+ border-top-left-radius: 0;
436
+ border-top-right-radius: 0; }
437
+
438
+ .select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple {
439
+ border-bottom: none;
440
+ border-bottom-left-radius: 0;
441
+ border-bottom-right-radius: 0; }
442
+
443
+ .select2-container--classic .select2-search--dropdown .select2-search__field {
444
+ border: 1px solid #aaa;
445
+ outline: 0; }
446
+
447
+ .select2-container--classic .select2-search--inline .select2-search__field {
448
+ outline: 0;
449
+ box-shadow: none; }
450
+
451
+ .select2-container--classic .select2-dropdown {
452
+ background-color: white;
453
+ border: 1px solid transparent; }
454
+
455
+ .select2-container--classic .select2-dropdown--above {
456
+ border-bottom: none; }
457
+
458
+ .select2-container--classic .select2-dropdown--below {
459
+ border-top: none; }
460
+
461
+ .select2-container--classic .select2-results > .select2-results__options {
462
+ max-height: 200px;
463
+ overflow-y: auto; }
464
+
465
+ .select2-container--classic .select2-results__option[role=group] {
466
+ padding: 0; }
467
+
468
+ .select2-container--classic .select2-results__option[aria-disabled=true] {
469
+ color: grey; }
470
+
471
+ .select2-container--classic .select2-results__option--highlighted[aria-selected] {
472
+ background-color: #3875d7;
473
+ color: white; }
474
+
475
+ .select2-container--classic .select2-results__group {
476
+ cursor: default;
477
+ display: block;
478
+ padding: 6px; }
479
+
480
+ .select2-container--classic.select2-container--open .select2-dropdown {
481
+ border-color: #5897fb; }
static/admin/css/vendor/select2/select2.min.css ADDED
@@ -0,0 +1 @@
 
 
1
+ .select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb}