diff --git a/booking_system/settings.py b/booking_system/settings.py index d0c5741..bcc70e3 100644 --- a/booking_system/settings.py +++ b/booking_system/settings.py @@ -14,7 +14,6 @@ DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '*').split(',') -# CORS Configuration CORS_ALLOWED_ORIGINS = [ 'https://attunehearttherapy.com' ] @@ -144,6 +143,42 @@ EMAIL_HOST_PASSWORD = os.getenv('SMTP_PASSWORD') DEFAULT_FROM_EMAIL = os.getenv('SMTP_FROM', 'admin@attunehearttherapy.com') +JITSI_BASE_URL = os.getenv('JITSI_BASE_URL', 'https://meet.jit.si') + +jitsi_base_url = os.getenv('JITSI_BASE_URL', 'https://meet.attunehearttherapy.com') +jitsi_domain = jitsi_base_url.replace('https://', '').replace('http://', '').strip('/') + +JITSI_CONFIG = { + 'DOMAIN': jitsi_domain, + 'APP_ID': os.getenv('JITSI_APP_ID', 'attunehearttherapy_id'), + 'SECRET_KEY': os.getenv('JITSI_PRIVATE_KEY', 'attunehearttherapy_jitsi_private_key'), + + 'CUSTOM_LOGO': os.getenv('JITSI_CUSTOM_LOGO', '/static/images/logo.png'), + 'BRAND_NAME': os.getenv('JITSI_BRAND_NAME', 'Attune Heart Therapy'), + + 'ENABLE_RECORDING': os.getenv('JITSI_ENABLE_RECORDING', 'False').lower() == 'true', + 'ENABLE_TRANSCRIPTION': os.getenv('JITSI_ENABLE_TRANSCRIPTION', 'False').lower() == 'true', + + 'ENABLE_LOBBY': True, + 'ENABLE_PASSWORD': True, + 'ENABLE_CLOSE_PAGE': True, + 'REQUIRE_DISPLAY_NAME': True, + + 'MODERATOR_CAN_MUTE': True, + 'MODERATOR_CAN_LOCK_ROOM': True, + 'MODERATOR_CAN_KICK': True, +} + +ADMIN_EMAILS = os.getenv('ADMIN_EMAILS', 'admin@attunehearttherapy.com').split(',') +THERAPIST_EMAILS = os.getenv('THERAPIST_EMAILS', 'admin@attunehearttherapy.com').split(',') +ADMIN_NOTIFICATION_EMAILS = os.getenv('ADMIN_NOTIFICATION_EMAILS', 'admin@attunehearttherapy.com').split(',') + +FEEDBACK_FORM_URL = os.getenv('FEEDBACK_FORM_URL', 'https://attunehearttherapy.com/feedback') + +MEETING_BUFFER_START = 15 +PATIENT_JOIN_EARLY = 5 + + REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_AUTHENTICATION_CLASSES': ( @@ -155,14 +190,24 @@ REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', ), + 'DEFAULT_PARSER_CLASSES': ( + 'rest_framework.parsers.JSONParser', + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser', + ), } - SPECTACULAR_SETTINGS = { 'TITLE': 'Attune Heart Therapy API', 'DESCRIPTION': 'API for managing appointments, meetings, and user authentication.', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, + 'TAGS': [ + {'name': 'appointments', 'description': 'Appointment booking and management'}, + {'name': 'meetings', 'description': 'Jitsi video meeting management'}, + {'name': 'availability', 'description': 'Therapist availability configuration'}, + {'name': 'users', 'description': 'User authentication and management'}, + ], } @@ -181,6 +226,9 @@ USE_TZ = True STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'static'), +] STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' @@ -205,4 +253,86 @@ LOGGING = { 'handlers': ['console'], 'level': 'INFO', }, +} + +JAZZMIN_SETTINGS = { + "site_title": "Attune Heart Therapy Admin", + "site_header": "Attune Heart Therapy", + "site_brand": "Attune Heart Therapy", + "site_logo": "/static/admin/img/heart-logo.png", + "login_logo": "/static/admin/img/heart-logo.png", + "login_logo_dark": "/static/admin/img/heart-logo.png", + "site_logo_classes": "img-circle", + "site_icon": "/static/admin/img/heart-logo.png", + "welcome_sign": "Welcome to Attune Heart Therapy Admin", + "copyright": "Attune Heart Therapy", + "search_model": ["users.CustomUser", "meetings.AppointmentRequest"], + "user_avatar": None, + "topmenu_links": [ + {"name": "Home", "url": "admin:index", "permissions": ["auth.view_user"]}, + {"name": "Support", "url": "https://attunehearttherapy.com/support", "new_window": True}, + {"model": "users.CustomUser"}, + {"app": "meetings"}, + ], + "show_sidebar": True, + "navigation_expanded": True, + "hide_apps": [], + "hide_models": [], + "order_with_respect_to": ["users", "meetings"], + "custom_links": { + "meetings": [{ + "name": "Meeting Dashboard", + "url": "meeting_dashboard", + "icon": "fas fa-video", + "permissions": ["meetings.view_appointmentrequest"] + }] + }, + "icons": { + "auth": "fas fa-users-cog", + "auth.user": "fas fa-user", + "auth.Group": "fas fa-users", + "meetings.AppointmentRequest": "fas fa-calendar-check", + "meetings.AdminWeeklyAvailability": "fas fa-clock", + "users.CustomUser": "fas fa-user-md", + }, + "default_icon_parents": "fas fa-chevron-circle-right", + "default_icon_children": "fas fa-circle", + "related_modal_active": False, + "custom_css": None, + "custom_js": None, + "show_ui_builder": False, + "changeform_format": "horizontal_tabs", + "changeform_format_overrides": {"meetings.appointmentrequest": "collapsible"}, +} + +JAZZMIN_UI_TWEAKS = { + "navbar_small_text": False, + "footer_small_text": False, + "body_small_text": False, + "brand_small_text": False, + "brand_colour": "navbar-primary", + "accent": "accent-primary", + "navbar": "navbar-white navbar-light", + "no_navbar_border": False, + "navbar_fixed": True, + "layout_boxed": False, + "footer_fixed": False, + "sidebar_fixed": True, + "sidebar": "sidebar-dark-primary", + "sidebar_nav_small_text": False, + "sidebar_disable_expand": False, + "sidebar_nav_child_indent": False, + "sidebar_nav_compact_style": False, + "sidebar_nav_legacy_style": False, + "sidebar_nav_flat_style": False, + "theme": "default", + "dark_mode_theme": None, + "button_classes": { + "primary": "btn-outline-primary", + "secondary": "btn-outline-secondary", + "info": "btn-info", + "warning": "btn-warning", + "danger": "btn-danger", + "success": "btn-success" + } } \ No newline at end of file diff --git a/meetings/migrations/0005_appointmentrequest_jitsi_meeting_config_and_more.py b/meetings/migrations/0005_appointmentrequest_jitsi_meeting_config_and_more.py new file mode 100644 index 0000000..55987b3 --- /dev/null +++ b/meetings/migrations/0005_appointmentrequest_jitsi_meeting_config_and_more.py @@ -0,0 +1,77 @@ +# Generated by Django 5.2.8 on 2025-12-01 19:18 + +import meetings.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meetings', '0004_remove_appointmentrequest_meetings_ap_jitsi_m_f3c488_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_meeting_config', + field=models.JSONField(default=dict, help_text='Jitsi meeting configuration and settings'), + ), + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_meeting_created', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_meeting_data', + field=models.JSONField(default=dict, help_text='Additional meeting data (participants, duration, etc)'), + ), + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_meeting_password', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_moderator_token', + field=meetings.models.EncryptedTextField(blank=True, null=True), + ), + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_participant_token', + field=meetings.models.EncryptedTextField(blank=True, null=True), + ), + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_recording_url', + field=models.URLField(blank=True, help_text='URL to meeting recording', null=True), + ), + migrations.AddField( + model_name='appointmentrequest', + name='meeting_duration_actual', + field=models.PositiveIntegerField(default=0, help_text='Actual meeting duration in minutes'), + ), + migrations.AddField( + model_name='appointmentrequest', + name='meeting_ended_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='appointmentrequest', + name='meeting_started_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='appointmentrequest', + name='jitsi_room_id', + field=models.CharField(blank=True, help_text='Jitsi room ID', max_length=100, null=True, unique=True), + ), + migrations.AddIndex( + model_name='appointmentrequest', + index=models.Index(fields=['jitsi_meeting_created', 'scheduled_datetime'], name='meetings_ap_jitsi_m_f3c488_idx'), + ), + migrations.AddIndex( + model_name='appointmentrequest', + index=models.Index(fields=['meeting_started_at'], name='meetings_ap_meeting_157142_idx'), + ), + ] diff --git a/meetings/migrations/0006_remove_appointmentrequest_jitsi_moderator_token_and_more.py b/meetings/migrations/0006_remove_appointmentrequest_jitsi_moderator_token_and_more.py new file mode 100644 index 0000000..15a631e --- /dev/null +++ b/meetings/migrations/0006_remove_appointmentrequest_jitsi_moderator_token_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.8 on 2025-12-02 10:24 + +import meetings.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meetings', '0005_appointmentrequest_jitsi_meeting_config_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='appointmentrequest', + name='jitsi_moderator_token', + ), + migrations.RemoveField( + model_name='appointmentrequest', + name='jitsi_participant_token', + ), + migrations.AlterField( + model_name='appointmentrequest', + name='jitsi_meet_url', + field=models.URLField(blank=True, help_text='Base Jitsi Meet URL (without tokens)', null=True), + ), + migrations.AlterField( + model_name='appointmentrequest', + name='jitsi_recording_url', + field=meetings.models.EncryptedURLField(blank=True, help_text='URL to meeting recording', max_length=2000, null=True), + ), + ] diff --git a/meetings/migrations/0007_appointmentrequest_jitsi_moderator_token_and_more.py b/meetings/migrations/0007_appointmentrequest_jitsi_moderator_token_and_more.py new file mode 100644 index 0000000..b38eefa --- /dev/null +++ b/meetings/migrations/0007_appointmentrequest_jitsi_moderator_token_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.8 on 2025-12-02 10:41 + +import meetings.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meetings', '0006_remove_appointmentrequest_jitsi_moderator_token_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_moderator_token', + field=meetings.models.EncryptedTextField(blank=True, null=True), + ), + migrations.AddField( + model_name='appointmentrequest', + name='jitsi_participant_token', + field=meetings.models.EncryptedTextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='appointmentrequest', + name='jitsi_meet_url', + field=models.URLField(blank=True, help_text='Jitsi Meet URL for the video session', null=True), + ), + migrations.AlterField( + model_name='appointmentrequest', + name='jitsi_recording_url', + field=models.URLField(blank=True, help_text='URL to meeting recording', null=True), + ), + ] diff --git a/meetings/migrations/0008_alter_appointmentrequest_jitsi_moderator_token_and_more.py b/meetings/migrations/0008_alter_appointmentrequest_jitsi_moderator_token_and_more.py new file mode 100644 index 0000000..7cafc3b --- /dev/null +++ b/meetings/migrations/0008_alter_appointmentrequest_jitsi_moderator_token_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-12-02 11:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meetings', '0007_appointmentrequest_jitsi_moderator_token_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='appointmentrequest', + name='jitsi_moderator_token', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='appointmentrequest', + name='jitsi_participant_token', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/meetings/migrations/0009_appointmentrequest_is_admin_join.py b/meetings/migrations/0009_appointmentrequest_is_admin_join.py new file mode 100644 index 0000000..2211760 --- /dev/null +++ b/meetings/migrations/0009_appointmentrequest_is_admin_join.py @@ -0,0 +1,22 @@ +# Generated manually for is_admin_join field + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meetings', '0008_alter_appointmentrequest_jitsi_moderator_token_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='appointmentrequest', + name='is_admin_join', + field=models.BooleanField( + default=False, + help_text='When True, participants are allowed to join the meeting. Admin must join first and set this to True.' + ), + ), + ] + diff --git a/meetings/models.py b/meetings/models.py index 9bd407a..b166feb 100644 --- a/meetings/models.py +++ b/meetings/models.py @@ -7,6 +7,11 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC import base64 import os +import secrets +import time +from datetime import datetime, timedelta +import jwt +import json class EncryptionManager: def __init__(self): @@ -79,6 +84,11 @@ class EncryptedTextField(models.TextField): return value return encryption_manager.encrypt_value(value) +class EncryptedURLField(EncryptedCharField): + def __init__(self, *args, **kwargs): + kwargs['max_length'] = kwargs.get('max_length', 200) + super().__init__(*args, **kwargs) + class AdminWeeklyAvailability(models.Model): DAYS_OF_WEEK = [ (0, 'Monday'), @@ -203,15 +213,44 @@ class AppointmentRequest(models.Model): ) rejection_reason = EncryptedTextField(blank=True) - jitsi_meet_url = models.URLField(blank=True, null=True, help_text="Jitsi Meet URL for the video session") + jitsi_meet_url = models.URLField( + blank=True, + null=True, + help_text="Jitsi Meet URL for the video session" + ) jitsi_room_id = models.CharField( max_length=100, unique=True, null=True, blank=True, - default=None, help_text="Jitsi room ID" ) + jitsi_meeting_created = models.BooleanField(default=False) + jitsi_moderator_token = models.TextField(blank=True, null=True) + jitsi_participant_token = models.TextField(blank=True, null=True) + jitsi_meeting_password = models.CharField(max_length=100, blank=True, null=True) + + jitsi_meeting_config = models.JSONField( + default=dict, + help_text="Jitsi meeting configuration and settings" + ) + jitsi_recording_url = models.URLField(blank=True, null=True, help_text="URL to meeting recording") + jitsi_meeting_data = models.JSONField( + default=dict, + help_text="Additional meeting data (participants, duration, etc)" + ) + + meeting_started_at = models.DateTimeField(null=True, blank=True) + meeting_ended_at = models.DateTimeField(null=True, blank=True) + meeting_duration_actual = models.PositiveIntegerField( + default=0, + help_text="Actual meeting duration in minutes" + ) + + is_admin_join = models.BooleanField( + default=False, + help_text="When True, participants are allowed to join the meeting. Admin must join first and set this to True." + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -223,8 +262,13 @@ class AppointmentRequest(models.Model): indexes = [ models.Index(fields=['status', 'scheduled_datetime']), models.Index(fields=['email', 'created_at']), + models.Index(fields=['jitsi_meeting_created', 'scheduled_datetime']), + models.Index(fields=['meeting_started_at']), ] + def __str__(self): + return f"{self.full_name} - {self.get_status_display()} - {self.created_at.strftime('%Y-%m-%d')}" + @property def full_name(self): return f"{self.first_name} {self.last_name}" @@ -241,7 +285,7 @@ class AppointmentRequest(models.Model): @property def has_jitsi_meeting(self): - return bool(self.jitsi_meet_url and self.jitsi_room_id) + return bool(self.jitsi_meet_url and self.jitsi_room_id and self.jitsi_meeting_created) @property def meeting_in_future(self): @@ -257,9 +301,24 @@ class AppointmentRequest(models.Model): return f"{hours}h {minutes}m" return f"{minutes}m" + @property + def meeting_join_ready(self): + if not self.has_jitsi_meeting: + return False + if not self.scheduled_datetime: + return False + if self.status != 'scheduled': + return False + + now = timezone.now() + meeting_start = self.scheduled_datetime + meeting_end = meeting_start + timedelta(minutes=self.scheduled_duration + 15) + + return meeting_start - timedelta(minutes=10) <= now <= meeting_end + def get_preferred_dates_display(self): try: - dates = [timezone.datetime.strptime(date, '%Y-%m-%d').strftime('%b %d, %Y') + dates = [datetime.strptime(date, '%Y-%m-%d').strftime('%b %d, %Y') for date in self.preferred_dates] return ', '.join(dates) except: @@ -273,114 +332,13 @@ class AppointmentRequest(models.Model): } return ', '.join([slot_display.get(slot, slot) for slot in self.preferred_time_slots]) - def generate_jitsi_room_id(self): - if not self.jitsi_room_id: - self.jitsi_room_id = f"therapy_session_{self.id.hex[:16]}" - return self.jitsi_room_id - - def create_jitsi_meeting(self): - if not self.jitsi_room_id: - self.generate_jitsi_room_id() - - jitsi_base_url = getattr(settings, 'JITSI_BASE_URL', 'https://meet.jit.si') - self.jitsi_meet_url = f"{jitsi_base_url}/{self.jitsi_room_id}" - return self.jitsi_meet_url - - def get_jitsi_join_info(self): - if not self.has_jitsi_meeting: - return None - - return { - 'meeting_url': self.jitsi_meet_url, - 'room_id': self.jitsi_room_id, - 'scheduled_time': self.formatted_scheduled_datetime, - 'duration': self.meeting_duration_display, - 'join_instructions': 'Click the meeting URL to join the video session. No password required.' - } - - def schedule_appointment(self, datetime_obj, duration=60, commit=True): - self.status = 'scheduled' - self.scheduled_datetime = datetime_obj - self.scheduled_duration = duration - self.rejection_reason = '' - - self.create_jitsi_meeting() - - if commit: - self.save() - - def reject_appointment(self, reason='', commit=True): - self.status = 'rejected' - self.rejection_reason = reason - self.scheduled_datetime = None - self.jitsi_meet_url = None - self.jitsi_room_id = None - if commit: - self.save() - - def cancel_appointment(self, reason='', commit=True): - self.status = 'cancelled' - self.rejection_reason = reason - if commit: - self.save() - - def complete_appointment(self, commit=True): - self.status = 'completed' - if commit: - self.save() - - def can_join_meeting(self): - if not self.scheduled_datetime or not self.has_jitsi_meeting: - return False - - if self.status != 'scheduled': - return False - - now = timezone.now() - meeting_start = self.scheduled_datetime - meeting_end = meeting_start + timezone.timedelta(minutes=self.scheduled_duration + 15) # 15 min buffer - - return meeting_start - timezone.timedelta(minutes=10) <= now <= meeting_end - - def get_meeting_status(self): - if not self.scheduled_datetime: - return "Not scheduled" - - now = timezone.now() - meeting_start = self.scheduled_datetime - - if now < meeting_start - timezone.timedelta(minutes=10): - return "Scheduled" - elif self.can_join_meeting(): - return "Ready to join" - elif now > meeting_start + timezone.timedelta(minutes=self.scheduled_duration): - return "Completed" - else: - return "Ended" - - def get_available_time_slots_for_date(self, date_str): - try: - from datetime import datetime - date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() - day_of_week = date_obj.weekday() - - availability = AdminWeeklyAvailability.objects.first() - if not availability: - return [] - - return availability.get_availability_for_day(day_of_week) - except Exception as e: - print(f"Error getting available slots: {e}") - return [] - def are_preferences_available(self): - availability = AdminWeeklyAvailability.objects.first() + availability = get_admin_availability() if not availability: return False for date_str in self.preferred_dates: try: - from datetime import datetime date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() day_of_week = date_obj.weekday() @@ -394,14 +352,13 @@ class AppointmentRequest(models.Model): return False def get_matching_availability(self): - availability = AdminWeeklyAvailability.objects.first() + availability = get_admin_availability() if not availability: return [] matching_slots = [] for date_str in self.preferred_dates: try: - from datetime import datetime date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() day_of_week = date_obj.weekday() day_name = dict(AdminWeeklyAvailability.DAYS_OF_WEEK).get(day_of_week) @@ -422,9 +379,420 @@ class AppointmentRequest(models.Model): return matching_slots - def __str__(self): - return f"{self.full_name} - {self.get_status_display()} - {self.created_at.strftime('%Y-%m-%d')}" + def generate_jitsi_room_id(self): + if not self.jitsi_room_id: + timestamp = int(time.time()) + unique_id = secrets.token_hex(4) + self.jitsi_room_id = f"appointment_{str(self.id).replace('-', '')}_{timestamp}" + return self.jitsi_room_id + def generate_jwt_token(self, user, user_type='participant'): + + if not self.jitsi_room_id: + self.generate_jitsi_room_id() + + jitsi_config = getattr(settings, 'JITSI_CONFIG', {}) + domain = jitsi_config['DOMAIN'] + app_id = jitsi_config['APP_ID'] + secret_key = jitsi_config['SECRET_KEY'] + + is_moderator = user_type == 'moderator' + + features = { + "recording": "true" if is_moderator and jitsi_config.get('ENABLE_RECORDING') else "false", + "screen-sharing": "true", + "moderation": "true" if is_moderator else "false" + } + if hasattr(user, 'full_name') and user.full_name: + user_name = user.full_name + elif hasattr(user, 'get_full_name'): + user_name = user.get_full_name() + else: + user_name = f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip() + if not user_name: + user_name = getattr(user, 'username', 'User') + + payload = { + 'aud': app_id, + 'iss': app_id, + 'sub': domain, + 'room': self.jitsi_room_id, + 'exp': int(time.time()) + 7200, + 'nbf': int(time.time()) - 60, + 'moderator': is_moderator, + 'context': { + 'user': { + 'id': str(user.id), + 'name': user_name, + 'email': getattr(user, 'email', ''), + 'affiliation': 'moderator' if is_moderator else 'participant' + }, + 'features': features + } + } + + debug_mode = getattr(settings, 'DEBUG', False) + if debug_mode: + print(f"\n=== Generating {user_type} JWT ===") + print(f"User: {user_name}") + print(f"Payload: {json.dumps(payload, indent=2)}") + + token = jwt.encode(payload, secret_key, algorithm='HS256') + + if isinstance(token, bytes): + token = token.decode('utf-8') + if debug_mode: + try: + decoded = jwt.decode(token, options={"verify_signature": False}) + print(f"Token verified successfully") + features_str = json.dumps(decoded.get('context', {}).get('features', {})) + if '":"' in features_str and '","' not in features_str: + print("WARNING: Malformed JSON detected in features!") + except Exception as e: + print(f"Error verifying token: {e}") + + self._store_token(token, user_type) + return token + + def _store_token(self, token, user_type): + if user_type == 'moderator': + self.jitsi_moderator_token = token + else: + self.jitsi_participant_token = token + + update_fields = ['updated_at'] + if user_type == 'moderator': + update_fields.append('jitsi_moderator_token') + else: + update_fields.append('jitsi_participant_token') + + self.save(update_fields=update_fields) + + def create_jitsi_meeting(self, moderator_user, participant_user=None, with_moderation=True, custom_config=None): + + if self.has_jitsi_meeting and not custom_config: + return self.jitsi_meet_url + + self.generate_jitsi_room_id() + moderator_token = self.generate_jwt_token(moderator_user, 'moderator') + if participant_user: + participant_token = self.generate_jwt_token(participant_user, 'participant') + else: + class GenericUser: + id = 'participant' + first_name = 'Participant' + last_name = '' + email = '' + participant_token = self.generate_jwt_token(GenericUser(), 'participant') + + jitsi_config = getattr(settings, 'JITSI_CONFIG', {}) + domain = jitsi_config.get('DOMAIN', 'meet.jit.si') + custom_logo = jitsi_config.get('CUSTOM_LOGO', '') + brand_name = jitsi_config.get('BRAND_NAME', 'Therapy Session') + + self.jitsi_meeting_password = secrets.token_urlsafe(8) + + meeting_config = { + 'domain': domain, + 'room_id': self.jitsi_room_id, + 'brand_name': brand_name, + 'with_moderation': with_moderation, + 'enable_recording': jitsi_config.get('ENABLE_RECORDING', False), + 'enable_transcription': jitsi_config.get('ENABLE_TRANSCRIPTION', False), + 'custom_logo': custom_logo, + 'created_at': timezone.now().isoformat(), + } + + if custom_config and isinstance(custom_config, dict): + meeting_config.update(custom_config) + + self.jitsi_meet_url = self._build_meeting_url( + domain=domain, + room_id=self.jitsi_room_id, + token=participant_token, + config=meeting_config, + is_moderator=False + ) + + self.jitsi_meeting_config = meeting_config + self.jitsi_meeting_created = True + + self.save() + return self.jitsi_meet_url + + def _build_meeting_url(self, domain, room_id, token, config, is_moderator=False): + base_url = f"https://{domain}/{room_id}" + + params = { + 'jwt': token, + 'config.startWithAudioMuted': 'true', + 'config.startWithVideoMuted': 'true', + 'config.prejoinPageEnabled': 'false', + 'config.requireDisplayName': 'true', + 'interfaceConfig.APP_NAME': config.get('brand_name', 'Therapy Session'), + 'interfaceConfig.SHOW_JITSI_WATERMARK': 'false', + 'interfaceConfig.SHOW_WATERMARK_FOR_GUESTS': 'false', + } + custom_logo = config.get('custom_logo') + if custom_logo: + params['interfaceConfig.DEFAULT_LOGO_URL'] = custom_logo + + if config.get('with_moderation', True): + params.update({ + 'config.enableLobby': 'true', + 'config.moderatorsCanMute': 'true', + 'config.moderatorsCanLockRoom': 'true', + 'config.moderatorsCanKick': 'true', + 'config.enableClosePage': 'true', + }) + if config.get('enable_recording', False) and is_moderator: + params['config.enableRecording'] = 'true' + + params_list = [f"{key}={value}" for key, value in params.items() if value is not None] + query_string = '&'.join(params_list) + + return f"{base_url}?{query_string}" + + def get_moderator_join_url(self, moderator_user): + self.generate_jwt_token(moderator_user, 'moderator') + + jitsi_config = getattr(settings, 'JITSI_CONFIG', {}) + domain = jitsi_config.get('DOMAIN', 'meet.jit.si') + brand_name = jitsi_config.get('BRAND_NAME', 'Therapist Console') + custom_logo = jitsi_config.get('CUSTOM_LOGO', '') + + moderator_params = { + 'jwt': self.jitsi_moderator_token, + 'config.startWithAudioMuted': 'true', + 'config.startWithVideoMuted': 'true', + 'config.enableLobby': str(jitsi_config.get('ENABLE_LOBBY', True)).lower(), + 'config.requireDisplayName': str(jitsi_config.get('REQUIRE_DISPLAY_NAME', True)).lower(), + 'config.moderatorsCanMute': str(jitsi_config.get('MODERATOR_CAN_MUTE', True)).lower(), + 'config.moderatorsCanLockRoom': str(jitsi_config.get('MODERATOR_CAN_LOCK_ROOM', True)).lower(), + 'config.moderatorsCanKick': str(jitsi_config.get('MODERATOR_CAN_KICK', True)).lower(), + 'config.enableClosePage': str(jitsi_config.get('ENABLE_CLOSE_PAGE', True)).lower(), + 'interfaceConfig.APP_NAME': brand_name, + 'interfaceConfig.SHOW_JITSI_WATERMARK': 'false', + 'interfaceConfig.SHOW_WATERMARK_FOR_GUESTS': 'false', + } + + if custom_logo: + moderator_params['interfaceConfig.DEFAULT_LOGO_URL'] = custom_logo + + if jitsi_config.get('ENABLE_RECORDING', False): + moderator_params['config.enableRecording'] = 'true' + + if self.jitsi_meeting_password and jitsi_config.get('ENABLE_PASSWORD', True): + moderator_params['config.password'] = self.jitsi_meeting_password + + params_list = [f"{key}={value}" for key, value in moderator_params.items() if value is not None] + query_string = '&'.join(params_list) + + return f"https://{domain}/{self.jitsi_room_id}?{query_string}" + + def get_participant_join_url(self, participant_user, include_password=True): + self.generate_jwt_token(participant_user, 'participant') + + jitsi_config = getattr(settings, 'JITSI_CONFIG', {}) + domain = jitsi_config.get('DOMAIN', 'meet.jit.si') + brand_name = jitsi_config.get('BRAND_NAME', 'Therapy Session') + custom_logo = jitsi_config.get('CUSTOM_LOGO', '') + + participant_params = { + 'jwt': self.jitsi_participant_token, + 'config.startWithAudioMuted': 'true', + 'config.startWithVideoMuted': 'true', + 'config.enableLobby': str(jitsi_config.get('ENABLE_LOBBY', True)).lower(), + 'config.requireDisplayName': str(jitsi_config.get('REQUIRE_DISPLAY_NAME', True)).lower(), + 'interfaceConfig.APP_NAME': brand_name, + 'interfaceConfig.SHOW_JITSI_WATERMARK': 'false', + 'interfaceConfig.SHOW_WATERMARK_FOR_GUESTS': 'false', + } + + if custom_logo: + participant_params['interfaceConfig.DEFAULT_LOGO_URL'] = custom_logo + + if include_password and self.jitsi_meeting_password and jitsi_config.get('ENABLE_PASSWORD', True): + participant_params['config.password'] = self.jitsi_meeting_password + + params_list = [f"{key}={value}" for key, value in participant_params.items() if value is not None] + query_string = '&'.join(params_list) + + return f"https://{domain}/{self.jitsi_room_id}?{query_string}" + + def can_join_meeting(self, user_type='participant'): + if not self.scheduled_datetime or not self.has_jitsi_meeting: + return False + + if self.status not in ['scheduled', 'in_progress']: + return False + + now = timezone.now() + meeting_start = self.scheduled_datetime + meeting_end = meeting_start + timedelta(minutes=self.scheduled_duration + 30) + + if user_type == 'moderator': + return meeting_start - timedelta(minutes=15) <= now <= meeting_end + timedelta(minutes=15) + else: + # Participants can only join when is_admin_join is True + if not self.is_admin_join: + return False + return meeting_start - timedelta(minutes=5) <= now <= meeting_end + + def schedule_appointment(self, datetime_obj, moderator_user, participant_user=None, duration=60, create_meeting=True, commit=True): + self.status = 'scheduled' + self.scheduled_datetime = datetime_obj + self.scheduled_duration = duration + self.rejection_reason = '' + + if create_meeting: + self.create_jitsi_meeting( + moderator_user=moderator_user, + participant_user=participant_user, + with_moderation=True + ) + + if commit: + self.save() + def reject_appointment(self, reason='', commit=True): + self.status = 'rejected' + self.rejection_reason = reason + self.scheduled_datetime = None + self.jitsi_meet_url = None + self.jitsi_room_id = None + if commit: + self.save() + + def cancel_appointment(self, reason='', commit=True): + self.status = 'cancelled' + self.rejection_reason = reason + if commit: + self.save() + + def complete_appointment(self, commit=True): + self.status = 'completed' + if commit: + self.save() + + def start_meeting(self, commit=True): + if self.status == 'scheduled': + self.meeting_started_at = timezone.now() + if commit: + self.save(update_fields=['meeting_started_at']) + + def end_meeting(self, commit=True): + if self.meeting_started_at and not self.meeting_ended_at: + self.meeting_ended_at = timezone.now() + + if self.meeting_started_at: + duration = self.meeting_ended_at - self.meeting_started_at + self.meeting_duration_actual = int(duration.total_seconds() / 60) + + if commit: + self.save(update_fields=[ + 'meeting_ended_at', + 'meeting_duration_actual' + ]) + + def can_join_meeting(self, *args, **kwargs): + if args: + user_type = args[0] + elif 'user_type' in kwargs: + user_type = kwargs['user_type'] + else: + user_type = 'participant' + + if not self.scheduled_datetime or not self.has_jitsi_meeting: + return False + + if self.status not in ['scheduled', 'in_progress']: + return False + + now = timezone.now() + meeting_start = self.scheduled_datetime + meeting_end = meeting_start + timedelta(minutes=self.scheduled_duration + 30) + + if user_type == 'moderator': + return meeting_start - timedelta(minutes=15) <= now <= meeting_end + timedelta(minutes=15) + else: + return meeting_start - timedelta(minutes=5) <= now <= meeting_end + + def get_meeting_join_info(self, user_type='participant'): + if not self.has_jitsi_meeting: + return None + + join_url = self.get_moderator_join_url() if user_type == 'moderator' else self.get_participant_join_url() + + return { + 'meeting_url': join_url, + 'room_id': self.jitsi_room_id, + 'scheduled_time': self.formatted_scheduled_datetime, + 'duration': self.meeting_duration_display, + 'password': self.jitsi_meeting_password if user_type == 'participant' else None, + 'can_join_now': self.can_join_meeting(user_type), + 'join_window_start': (self.scheduled_datetime - timedelta(minutes=15)).strftime("%I:%M %p") if user_type == 'moderator' else (self.scheduled_datetime - timedelta(minutes=5)).strftime("%I:%M %p"), + 'join_window_end': (self.scheduled_datetime + timedelta(minutes=self.scheduled_duration + 30)).strftime("%I:%M %p"), + 'status': self.get_status_display(), + } + + def update_meeting_data(self, data): + if not isinstance(data, dict): + return + + current_data = self.jitsi_meeting_data or {} + current_data.update(data) + self.jitsi_meeting_data = current_data + self.save(update_fields=['jitsi_meeting_data']) + + def get_meeting_analytics(self): + return { + 'scheduled_duration': self.scheduled_duration, + 'actual_duration': self.meeting_duration_actual, + 'started_at': self.meeting_started_at.isoformat() if self.meeting_started_at else None, + 'ended_at': self.meeting_ended_at.isoformat() if self.meeting_ended_at else None, + 'status': self.status, + 'punctuality': self._calculate_punctuality(), + 'efficiency': self._calculate_efficiency(), + } + + def _calculate_punctuality(self): + if not self.meeting_started_at or not self.scheduled_datetime: + return None + + delay = (self.meeting_started_at - self.scheduled_datetime).total_seconds() / 60 + if abs(delay) <= 5: + return 'On time' + elif delay > 5: + return f'Late by {int(delay)} minutes' + else: + return f'Early by {int(abs(delay))} minutes' + + def _calculate_efficiency(self): + if not self.meeting_duration_actual or not self.scheduled_duration: + return None + + efficiency = (self.meeting_duration_actual / self.scheduled_duration) * 100 + if efficiency <= 100: + return f'{int(efficiency)}% (Within schedule)' + else: + return f'{int(efficiency)}% (Overtime)' + + + def get_meeting_status(self): + if not self.scheduled_datetime: + return "Not scheduled" + + now = timezone.now() + meeting_start = self.scheduled_datetime + + if now < meeting_start - timedelta(minutes=10): + return "Scheduled" + elif self.can_join_meeting(): + return "Ready to join" + elif now > meeting_start + timedelta(minutes=self.scheduled_duration): + return "Completed" + else: + return "Ended" def get_admin_availability(): availability, created = AdminWeeklyAvailability.objects.get_or_create( diff --git a/meetings/serializers.py b/meetings/serializers.py index c12c1a0..4dee7aa 100644 --- a/meetings/serializers.py +++ b/meetings/serializers.py @@ -69,11 +69,16 @@ class AppointmentRequestSerializer(serializers.ModelSerializer): has_jitsi_meeting = serializers.ReadOnlyField() jitsi_meet_url = serializers.ReadOnlyField() jitsi_room_id = serializers.ReadOnlyField() - can_join_meeting = serializers.ReadOnlyField() + can_join_meeting = serializers.SerializerMethodField() + can_join_as_moderator = serializers.SerializerMethodField() + can_join_as_participant = serializers.SerializerMethodField() meeting_status = serializers.ReadOnlyField() meeting_duration_display = serializers.ReadOnlyField() matching_availability = serializers.SerializerMethodField() are_preferences_available = serializers.SerializerMethodField() + moderator_join_url = serializers.SerializerMethodField() + participant_join_url = serializers.SerializerMethodField() + meeting_analytics = serializers.SerializerMethodField() class Meta: model = AppointmentRequest @@ -84,23 +89,140 @@ class AppointmentRequestSerializer(serializers.ModelSerializer): 'jitsi_meet_url', 'jitsi_room_id', 'created_at', 'updated_at', 'full_name', 'formatted_created_at', 'formatted_scheduled_datetime', 'preferred_dates_display', 'preferred_time_slots_display', - 'has_jitsi_meeting', 'can_join_meeting', 'meeting_status', - 'meeting_duration_display', 'matching_availability', 'are_preferences_available' + 'has_jitsi_meeting', 'can_join_meeting', 'can_join_as_moderator', 'can_join_as_participant', + 'meeting_status', 'meeting_duration_display', 'matching_availability', + 'are_preferences_available', 'moderator_join_url', 'participant_join_url', + 'meeting_analytics', 'is_admin_join' ] read_only_fields = [ 'id', 'status', 'scheduled_datetime', 'scheduled_duration', - 'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id', - 'created_at', 'updated_at' + 'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id', + 'created_at', 'updated_at', ] + def get_can_join_meeting(self, obj): + return obj.can_join_meeting('participant') + + def get_can_join_as_moderator(self, obj): + return obj.can_join_meeting('moderator') + + def get_can_join_as_participant(self, obj): + return obj.can_join_meeting('participant') + + def get_moderator_join_url(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return None + + user = request.user + + is_authorized = ( + user.is_staff or + (hasattr(user, 'is_therapist') and user.is_therapist) or + (hasattr(obj, 'created_by_id') and str(user.id) == str(obj.created_by_id)) + ) + + if is_authorized and obj.has_jitsi_meeting: + return obj.get_moderator_join_url(moderator_user=user) + + return None + + def get_participant_join_url(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return None + + if not obj.has_jitsi_meeting or not obj.email: + return None + + # Try to get the participant user from the appointment's email + participant_user = None + try: + from users.models import CustomUser + participant_user = CustomUser.objects.get(email=obj.email) + except CustomUser.DoesNotExist: + # Participant user doesn't exist (not registered) + return None + + # Check if current user is authorized to view this URL + current_user = request.user + is_authorized = ( + current_user.is_staff or # Staff can see participant URLs for all appointments + current_user.email == obj.email or # Participant can see their own URL + (hasattr(obj, 'invited_participants') and current_user.email in obj.invited_participants) + ) + + if not is_authorized: + return None + + # Staff can always see the URL (even when is_admin_join is False) + # But actual participants can only see it when is_admin_join is True + if current_user.is_staff: + # Staff can see the URL regardless of is_admin_join status + return obj.get_participant_join_url( + participant_user=participant_user, + include_password=True + ) + else: + # Participants can only see the URL when is_admin_join is True + if obj.is_admin_join: + return obj.get_participant_join_url( + participant_user=participant_user, + include_password=True + ) + return None + + def get_meeting_analytics(self, obj): + return { + 'scheduled_duration': obj.scheduled_duration, + 'actual_duration': obj.meeting_duration_actual if hasattr(obj, 'meeting_duration_actual') else 0, + 'started_at': obj.meeting_started_at if hasattr(obj, 'meeting_started_at') else None, + 'ended_at': obj.meeting_ended_at if hasattr(obj, 'meeting_ended_at') else None, + 'status': obj.status, + 'punctuality': self._calculate_punctuality(obj), + 'efficiency': self._calculate_efficiency(obj), + } + + def _calculate_punctuality(self, obj): + if not hasattr(obj, 'meeting_started_at') or not obj.meeting_started_at: + return None + if not obj.scheduled_datetime: + return None + + delay = obj.meeting_started_at - obj.scheduled_datetime + delay_minutes = delay.total_seconds() / 60 + + if delay_minutes <= 5: + return 'on_time' + elif delay_minutes <= 15: + return 'slightly_late' + else: + return 'late' + + def _calculate_efficiency(self, obj): + if not hasattr(obj, 'meeting_started_at') or not hasattr(obj, 'meeting_ended_at'): + return None + if not obj.meeting_started_at or not obj.meeting_ended_at: + return None + if not obj.scheduled_duration: + return None + + actual_duration = (obj.meeting_ended_at - obj.meeting_started_at).total_seconds() / 60 + scheduled_duration = obj.scheduled_duration + + if actual_duration <= scheduled_duration: + return 'efficient' + elif actual_duration <= scheduled_duration * 1.2: + return 'slightly_over' + else: + return 'over_time' + def get_matching_availability(self, obj): - """Get matching availability for this appointment request""" return obj.get_matching_availability() def get_are_preferences_available(self, obj): - """Check if preferences match admin availability""" return obj.are_preferences_available() - + class AppointmentRequestCreateSerializer(serializers.ModelSerializer): selected_slots = serializers.ListField( child=serializers.DictField(), @@ -222,8 +344,6 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer): return data def _convert_slots_to_dates(self, selected_slots): - from datetime import timedelta - today = timezone.now().date() preferred_dates = [] @@ -253,12 +373,15 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer): def create(self, validated_data): return super().create(validated_data) - + + class AppointmentScheduleSerializer(serializers.Serializer): scheduled_datetime = serializers.DateTimeField() scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240) date_str = serializers.CharField(required=False, write_only=True) time_slot = serializers.CharField(required=False, write_only=True) + create_jitsi_meeting = serializers.BooleanField(default=True) + jitsi_custom_config = serializers.JSONField(required=False, default=dict) def validate(self, data): scheduled_datetime = data.get('scheduled_datetime') @@ -307,6 +430,289 @@ class AppointmentScheduleSerializer(serializers.Serializer): if value > 240: raise serializers.ValidationError("Duration cannot exceed 4 hours.") return value + + def save(self, appointment, moderator_user): + scheduled_datetime = self.validated_data['scheduled_datetime'] + scheduled_duration = self.validated_data.get('scheduled_duration', 60) + create_meeting = self.validated_data.get('create_jitsi_meeting', True) + custom_config = self.validated_data.get('jitsi_custom_config', {}) + + appointment.schedule_appointment( + datetime_obj=scheduled_datetime, + duration=scheduled_duration, + create_meeting=False, + moderator_user=moderator_user, + commit=False + ) + + if create_meeting: + if custom_config: + appointment.create_jitsi_meeting( + moderator_user=moderator_user, + with_moderation=True, + custom_config=custom_config + ) + else: + appointment.create_jitsi_meeting( + moderator_user=moderator_user, + with_moderation=True + ) + + appointment.save() + return appointment + + def to_representation(self, instance): + representation = { + 'id': str(instance.id), + 'status': instance.status, + 'scheduled_datetime': instance.scheduled_datetime, + 'scheduled_duration': instance.scheduled_duration, + 'jitsi_meeting_created': instance.jitsi_meeting_created, + } + + if instance.has_jitsi_meeting: + representation['jitsi_room_id'] = instance.jitsi_room_id + representation['jitsi_meet_url'] = instance.jitsi_meet_url + representation['jitsi_meeting_password'] = instance.jitsi_meeting_password + + return representation + + +class AppointmentDetailSerializer(serializers.ModelSerializer): + meeting_info = serializers.SerializerMethodField() + meeting_analytics = serializers.SerializerMethodField() + can_join_as_moderator = serializers.SerializerMethodField() + can_join_as_participant = serializers.SerializerMethodField() + moderator_join_url = serializers.SerializerMethodField() + participant_join_url = serializers.SerializerMethodField() + + class Meta: + model = AppointmentRequest + fields = [ + 'id', 'first_name', 'last_name', 'email', 'phone', 'reason', + 'preferred_dates', 'preferred_time_slots', 'status', + 'scheduled_datetime', 'scheduled_duration', 'rejection_reason', + 'jitsi_meet_url', 'jitsi_room_id', 'jitsi_meeting_created', + 'meeting_started_at', 'meeting_ended_at', 'meeting_duration_actual', + 'created_at', 'updated_at', + 'meeting_info', 'meeting_analytics', + 'can_join_as_moderator', 'can_join_as_participant', + 'moderator_join_url', 'participant_join_url', 'is_admin_join', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_meeting_info(self, obj): + if not obj.has_jitsi_meeting: + return None + + return { + 'has_meeting': obj.has_jitsi_meeting, + 'meeting_ready': getattr(obj, 'meeting_join_ready', False), + 'room_id': obj.jitsi_room_id, + 'password_set': bool(obj.jitsi_meeting_password), + } + + def get_meeting_analytics(self, obj): + if not obj.has_jitsi_meeting: + return None + + analytics = { + 'scheduled_duration': obj.scheduled_duration, + 'actual_duration': getattr(obj, 'meeting_duration_actual', 0), + 'started_at': getattr(obj, 'meeting_started_at', None), + 'ended_at': getattr(obj, 'meeting_ended_at', None), + 'status': obj.status, + } + if hasattr(obj, 'meeting_started_at') and obj.meeting_started_at and obj.scheduled_datetime: + delay = obj.meeting_started_at - obj.scheduled_datetime + delay_minutes = delay.total_seconds() / 60 + + if delay_minutes <= 5: + analytics['punctuality'] = 'on_time' + elif delay_minutes <= 15: + analytics['punctuality'] = 'slightly_late' + else: + analytics['punctuality'] = 'late' + else: + analytics['punctuality'] = None + if (hasattr(obj, 'meeting_started_at') and hasattr(obj, 'meeting_ended_at') and + obj.meeting_started_at and obj.meeting_ended_at and obj.scheduled_duration): + actual_duration = (obj.meeting_ended_at - obj.meeting_started_at).total_seconds() / 60 + scheduled_duration = obj.scheduled_duration + + if actual_duration <= scheduled_duration: + analytics['efficiency'] = 'efficient' + elif actual_duration <= scheduled_duration * 1.2: + analytics['efficiency'] = 'slightly_over' + else: + analytics['efficiency'] = 'over_time' + else: + analytics['efficiency'] = None + + return analytics + + def get_can_join_as_moderator(self, obj): + return obj.can_join_meeting(user_type='moderator') + + def get_can_join_as_participant(self, obj): + return obj.can_join_meeting(user_type='participant') + + def get_moderator_join_url(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return None + + user = request.user + + is_authorized = ( + user.is_staff or + (hasattr(user, 'is_therapist') and user.is_therapist) or + (hasattr(obj, 'created_by_id') and str(user.id) == str(obj.created_by_id)) + ) + + if is_authorized and obj.has_jitsi_meeting: + return obj.get_moderator_join_url(moderator_user=user) + + return None + + def get_participant_join_url(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return None + + if not obj.has_jitsi_meeting or not obj.email: + return None + + # Try to get the participant user from the appointment's email + participant_user = None + try: + from users.models import CustomUser + participant_user = CustomUser.objects.get(email=obj.email) + except CustomUser.DoesNotExist: + # Participant user doesn't exist (not registered) + return None + + # Check if current user is authorized to view this URL + current_user = request.user + is_authorized = ( + current_user.is_staff or # Staff can see participant URLs for all appointments + current_user.email == obj.email or # Participant can see their own URL + (hasattr(obj, 'invited_participants') and current_user.email in obj.invited_participants) + ) + + if not is_authorized: + return None + + # Staff can always see the URL (even when is_admin_join is False) + # But actual participants can only see it when is_admin_join is True + if current_user.is_staff: + # Staff can see the URL regardless of is_admin_join status + return obj.get_participant_join_url( + participant_user=participant_user, + include_password=True + ) + else: + # Participants can only see the URL when is_admin_join is True + if obj.is_admin_join: + return obj.get_participant_join_url( + participant_user=participant_user, + include_password=True + ) + return None + +class JitsiMeetingSerializer(serializers.ModelSerializer): + moderator_join_url = serializers.SerializerMethodField() + participant_join_url = serializers.SerializerMethodField() + meeting_config = serializers.SerializerMethodField() + + class Meta: + model = AppointmentRequest + fields = [ + 'id', 'jitsi_meet_url', 'jitsi_room_id', 'jitsi_meeting_created', + 'jitsi_meeting_password', 'jitsi_meeting_config', + 'moderator_join_url', 'participant_join_url', 'meeting_config', + ] + read_only_fields = ['id'] + + def get_moderator_join_url(self, obj): + if not obj.has_jitsi_meeting: + return None + return obj.get_moderator_join_url() + + def get_participant_join_url(self, obj): + if not obj.has_jitsi_meeting: + return None + return obj.get_participant_join_url() + + def get_meeting_config(self, obj): + if not obj.jitsi_meeting_config: + return {} + return obj.jitsi_meeting_config + + def update(self, instance, validated_data): + if not instance.has_jitsi_meeting: + custom_config = validated_data.get('jitsi_meeting_config', {}) + instance.create_jitsi_meeting( + with_moderation=True, + custom_config=custom_config + ) + + return super().update(instance, validated_data) + + +class MeetingJoinSerializer(serializers.Serializer): + user_type = serializers.ChoiceField( + choices=['moderator', 'participant'], + default='participant' + ) + token = serializers.CharField(required=False, allow_blank=True) + + def validate(self, data): + appointment = self.context.get('appointment') + if not appointment: + raise serializers.ValidationError("Appointment context required") + + user_type = data.get('user_type') + token = data.get('token') + if user_type == 'participant' and token: + expected_token = appointment.jitsi_participant_token + if expected_token and not expected_token.startswith(token[:20]): + raise serializers.ValidationError("Invalid join token") + + if not appointment.can_join_meeting(user_type): + raise serializers.ValidationError( + f"Cannot join meeting as {user_type}. " + f"Meeting window is not open or meeting has ended." + ) + + return data + + +class MeetingActionSerializer(serializers.Serializer): + action = serializers.ChoiceField( + choices=['start', 'end', 'update_metadata', 'record', 'allow_participants', 'disallow_participants'] + ) + metadata = serializers.JSONField(required=False, default=dict) + recording_url = serializers.URLField(required=False, allow_blank=True) + + def validate(self, data): + appointment = self.context.get('appointment') + if not appointment: + raise serializers.ValidationError("Appointment context required") + + action = data.get('action') + + if action == 'start' and appointment.meeting_started_at: + raise serializers.ValidationError("Meeting already started") + + if action == 'end' and appointment.meeting_ended_at: + raise serializers.ValidationError("Meeting already ended") + + if action == 'end' and not appointment.meeting_started_at: + raise serializers.ValidationError("Meeting not started yet") + + return data + class AppointmentRejectSerializer(serializers.Serializer): rejection_reason = serializers.CharField(required=False, allow_blank=True) diff --git a/meetings/urls.py b/meetings/urls.py index 800a6f5..a258020 100644 --- a/meetings/urls.py +++ b/meetings/urls.py @@ -14,28 +14,90 @@ from .views import ( AppointmentStatsView, UserAppointmentStatsView, MatchingAvailabilityView, + JoinMeetingView, + MeetingActionView, + UpcomingMeetingsView, + MeetingAnalyticsView, + BulkMeetingActionsView, availability_overview ) urlpatterns = [ + # Admin Availability URLs path('admin/availability/', AdminAvailabilityView.as_view(), name='admin-availability'), path('availability/config/', AdminAvailabilityConfigView.as_view(), name='availability-config'), path('availability/check/', CheckDateAvailabilityView.as_view(), name='check-availability'), path('availability/weekly/', WeeklyAvailabilityView.as_view(), name='weekly-availability'), path('availability/overview/', availability_overview, name='availability-overview'), + path('appointments/available-dates/', AvailableDatesView.as_view(), name='available-dates'), + # Appointment Request URLs path('appointments/', AppointmentRequestListView.as_view(), name='appointment-list'), path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'), path('appointments//', AppointmentRequestDetailView.as_view(), name='appointment-detail'), path('appointments//matching-availability/', MatchingAvailabilityView.as_view(), name='matching-availability'), + # Appointment Management URLs path('appointments//schedule/', ScheduleAppointmentView.as_view(), name='appointment-schedule'), path('appointments//reject/', RejectAppointmentView.as_view(), name='appointment-reject'), - path('appointments/available-dates/', AvailableDatesView.as_view(), name='available-dates'), - + # User-specific URLs path('user/appointments/', UserAppointmentsView.as_view(), name='user-appointments'), - - path('appointments/stats/', AppointmentStatsView.as_view(), name='appointment-stats'), path('user/appointments/stats/', UserAppointmentStatsView.as_view(), name='user-appointment-stats'), + + # Stats URLs + path('appointments/stats/', AppointmentStatsView.as_view(), name='appointment-stats'), + + + # Meeting Join URLs + path('appointments//join-meeting/', JoinMeetingView.as_view(), name='join-meeting'), + path('appointments//join-meeting/participant/', + JoinMeetingView.as_view(), {'user_type': 'participant'}, name='join-meeting-participant'), + path('appointments//join-meeting/moderator/', + JoinMeetingView.as_view(), {'user_type': 'moderator'}, name='join-meeting-moderator'), + + # Meeting Actions + path('appointments//meeting-actions/', MeetingActionView.as_view(), name='meeting-actions'), + path('appointments//start-meeting/', + MeetingActionView.as_view(), {'action': 'start'}, name='start-meeting'), + path('appointments//end-meeting/', + MeetingActionView.as_view(), {'action': 'end'}, name='end-meeting'), + path('appointments//update-meeting-metadata/', + MeetingActionView.as_view(), {'action': 'update_metadata'}, name='update-meeting-metadata'), + path('appointments//save-recording/', + MeetingActionView.as_view(), {'action': 'record'}, name='save-recording'), + + # Meeting Views + path('meetings/upcoming/', UpcomingMeetingsView.as_view(), name='upcoming-meetings'), + path('meetings/today/', UpcomingMeetingsView.as_view(), {'today_only': True}, name='today-meetings'), + path('meetings/past/', UpcomingMeetingsView.as_view(), {'past_only': True}, name='past-meetings'), + + # Meeting Analytics + path('appointments//meeting-analytics/', MeetingAnalyticsView.as_view(), name='meeting-analytics'), + path('meetings/analytics/summary/', MeetingAnalyticsView.as_view(), {'summary': True}, name='meeting-analytics-summary'), + + # Bulk Meeting Operations + path('meetings/bulk-actions/', BulkMeetingActionsView.as_view(), name='bulk-meeting-actions'), + path('meetings/bulk-create/', + BulkMeetingActionsView.as_view(), {'action': 'create_jitsi_meetings'}, name='bulk-create-meetings'), + path('meetings/bulk-send-reminders/', + BulkMeetingActionsView.as_view(), {'action': 'send_reminders'}, name='bulk-send-reminders'), + path('meetings/bulk-end-old/', + BulkMeetingActionsView.as_view(), {'action': 'end_old_meetings'}, name='bulk-end-old-meetings'), + + # Meeting Quick Actions (simplified endpoints) + path('meetings//quick-join/', + JoinMeetingView.as_view(), {'quick_join': True}, name='quick-join-meeting'), + path('meetings//quick-join/patient/', + JoinMeetingView.as_view(), {'quick_join': True, 'user_type': 'participant'}, name='quick-join-patient'), + path('meetings//quick-join/therapist/', + JoinMeetingView.as_view(), {'quick_join': True, 'user_type': 'moderator'}, name='quick-join-therapist'), + + # Meeting Status & Info + path('meetings//status/', MeetingActionView.as_view(), {'get_status': True}, name='meeting-status'), + path('meetings//info/', JoinMeetingView.as_view(), {'info_only': True}, name='meeting-info'), + + # Meeting Webhook/Notification endpoints (for Jitsi callbacks) + path('meetings/webhook/jitsi/', MeetingActionView.as_view(), {'webhook': True}, name='jitsi-webhook'), + path('meetings/recording-callback/', MeetingActionView.as_view(), {'recording_callback': True}, name='recording-callback'), ] \ No newline at end of file diff --git a/meetings/views.py b/meetings/views.py index 7c3bb44..3e929e4 100644 --- a/meetings/views.py +++ b/meetings/views.py @@ -2,6 +2,7 @@ from rest_framework import generics, status from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny, IsAdminUser +from rest_framework.views import APIView from django.utils import timezone from datetime import datetime, timedelta from .models import AdminWeeklyAvailability, AppointmentRequest, get_admin_availability, set_admin_availability, get_available_slots_for_week, check_date_availability @@ -15,7 +16,11 @@ from .serializers import ( AvailabilityCheckSerializer, AvailabilityResponseSerializer, WeeklyAvailabilitySerializer, - AdminAvailabilityConfigSerializer + AdminAvailabilityConfigSerializer, + AppointmentDetailSerializer, + JitsiMeetingSerializer, + MeetingJoinSerializer, + MeetingActionSerializer ) from .email_service import EmailService from users.models import CustomUser @@ -49,7 +54,6 @@ class AdminAvailabilityConfigView(generics.GenericAPIView): config = AdminAvailabilityConfigSerializer.get_default_config() return Response(config.data) - class AppointmentRequestListView(generics.ListAPIView): serializer_class = AppointmentRequestSerializer permission_classes = [IsAuthenticated] @@ -80,10 +84,9 @@ class AppointmentRequestCreateView(generics.CreateAPIView): class AppointmentRequestDetailView(generics.RetrieveAPIView): permission_classes = [IsAuthenticated] queryset = AppointmentRequest.objects.all() - serializer_class = AppointmentRequestSerializer + serializer_class = AppointmentDetailSerializer lookup_field = 'pk' - class ScheduleAppointmentView(generics.GenericAPIView): permission_classes = [IsAuthenticated, IsAdminUser] serializer_class = AppointmentScheduleSerializer @@ -102,18 +105,53 @@ class ScheduleAppointmentView(generics.GenericAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + create_jitsi_meeting = serializer.validated_data.get('create_jitsi_meeting', True) + jitsi_custom_config = serializer.validated_data.get('jitsi_custom_config', {}) + + admin_user = request.user + appointment.schedule_appointment( datetime_obj=serializer.validated_data['scheduled_datetime'], - duration=serializer.validated_data['scheduled_duration'] + duration=serializer.validated_data['scheduled_duration'], + create_meeting=create_jitsi_meeting, + moderator_user=admin_user, + commit=False ) + if jitsi_custom_config and create_jitsi_meeting: + if appointment.has_jitsi_meeting: + appointment.jitsi_meeting_config.update(jitsi_custom_config) + else: + appointment.create_jitsi_meeting( + moderator_user=admin_user, + with_moderation=True, + custom_config=jitsi_custom_config + ) + + appointment.save() EmailService.send_appointment_scheduled(appointment) - response_serializer = AppointmentRequestSerializer(appointment) + response_serializer = AppointmentDetailSerializer(appointment) + + # Try to get participant user from appointment email + participant_user = None + if appointment.email: + try: + participant_user = CustomUser.objects.get(email=appointment.email) + except CustomUser.DoesNotExist: + participant_user = None + return Response({ **response_serializer.data, - 'message': 'Appointment scheduled successfully. Jitsi meeting created.', - 'jitsi_meeting_created': appointment.has_jitsi_meeting + 'message': 'Appointment scheduled successfully.', + 'jitsi_meeting_created': appointment.has_jitsi_meeting, + 'moderator_name': admin_user.get_full_name() or admin_user.username, + 'moderator_join_url': appointment.get_moderator_join_url( + moderator_user=admin_user + ) if appointment.has_jitsi_meeting else None, + 'participant_join_url': appointment.get_participant_join_url( + participant_user=participant_user + ) if appointment.has_jitsi_meeting and participant_user else None, }) @@ -140,7 +178,7 @@ class RejectAppointmentView(generics.GenericAPIView): ) EmailService.send_appointment_rejected(appointment) - response_serializer = AppointmentRequestSerializer(appointment) + response_serializer = AppointmentDetailSerializer(appointment) return Response(response_serializer.data) @@ -232,10 +270,10 @@ class WeeklyAvailabilityView(generics.GenericAPIView): }) return Response(weekly_availability) - + class UserAppointmentsView(generics.ListAPIView): permission_classes = [IsAuthenticated] - serializer_class = AppointmentRequestSerializer + serializer_class = AppointmentDetailSerializer def get_queryset(self): user_email = self.request.user.email.lower() @@ -250,7 +288,8 @@ class UserAppointmentsView(generics.ListAPIView): return AppointmentRequest.objects.filter( id__in=appointment_ids ).order_by('-created_at') - + + class AppointmentStatsView(generics.GenericAPIView): permission_classes = [IsAuthenticated, IsAdminUser] @@ -262,6 +301,12 @@ class AppointmentStatsView(generics.GenericAPIView): rejected = AppointmentRequest.objects.filter(status='rejected').count() completed = AppointmentRequest.objects.filter(status='completed').count() + jitsi_meetings = AppointmentRequest.objects.filter(jitsi_meeting_created=True).count() + active_meetings = AppointmentRequest.objects.filter( + status='scheduled', + scheduled_datetime__gt=timezone.now() + ).count() + availability = get_admin_availability() availability_coverage = 0 if availability and availability.availability_schedule: @@ -277,12 +322,16 @@ class AppointmentStatsView(generics.GenericAPIView): 'users': users, 'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0, 'availability_coverage': availability_coverage, - 'available_days_count': days_with_availability if availability else 0 + 'available_days_count': days_with_availability if availability else 0, + 'jitsi_meetings_created': jitsi_meetings, + 'active_upcoming_meetings': active_meetings, + 'meetings_with_video': round((jitsi_meetings / total * 100), 2) if total > 0 else 0, }) + class UserAppointmentStatsView(generics.GenericAPIView): permission_classes = [IsAuthenticated] - serializer_class = AppointmentRequestSerializer + serializer_class = AppointmentDetailSerializer def get_queryset(self): user_email = self.request.user.email.lower() @@ -298,7 +347,6 @@ class UserAppointmentStatsView(generics.GenericAPIView): id__in=appointment_ids ) - def get(self, request, *args, **kwargs): queryset = self.get_queryset() @@ -308,6 +356,7 @@ class UserAppointmentStatsView(generics.GenericAPIView): 'scheduled': queryset.filter(status='scheduled').count(), 'rejected': queryset.filter(status='rejected').count(), 'completed': queryset.filter(status='completed').count(), + 'video_meetings': queryset.filter(jitsi_meeting_created=True).count(), } total = stats['total'] @@ -320,10 +369,16 @@ class UserAppointmentStatsView(generics.GenericAPIView): 'scheduled': scheduled, 'rejected': stats['rejected'], 'completed': stats['completed'], + 'video_meetings': stats['video_meetings'], 'completion_rate': completion_rate, - 'email': request.user.email + 'email': request.user.email, + 'upcoming_video_sessions': queryset.filter( + status='scheduled', + jitsi_meeting_created=True, + scheduled_datetime__gt=timezone.now() + ).count(), }) - + class MatchingAvailabilityView(generics.GenericAPIView): permission_classes = [IsAuthenticated] @@ -352,6 +407,336 @@ class MatchingAvailabilityView(generics.GenericAPIView): ) +class JoinMeetingView(generics.GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = MeetingJoinSerializer + queryset = AppointmentRequest.objects.all() + lookup_field = 'pk' + + def get(self, request, pk): + appointment = self.get_object() + + # Check permissions + if not self._has_join_permission(request.user, appointment): + return Response( + {'error': 'You do not have permission to join this meeting'}, + status=status.HTTP_403_FORBIDDEN + ) + user_type = 'moderator' if request.user.is_staff else 'participant' + + join_info = appointment.get_meeting_join_info(user_type) + + return Response({ + 'appointment_id': str(appointment.id), + 'user_type': user_type, + 'user_name': request.user.get_full_name() or request.user.email, + 'meeting_info': join_info, + 'can_join_now': appointment.can_join_meeting(user_type), + 'is_admin_join': appointment.is_admin_join, + 'participant_join_enabled': appointment.is_admin_join if user_type == 'participant' else None, + }) + + def post(self, request, pk): + appointment = self.get_object() + if not self._has_join_permission(request.user, appointment): + return Response( + {'error': 'You do not have permission to join this meeting'}, + status=status.HTTP_403_FORBIDDEN + ) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user_type = serializer.validated_data.get('user_type') + + if request.user.is_staff and user_type != 'moderator': + user_type = 'moderator' + elif not request.user.is_staff and user_type == 'moderator': + return Response( + {'error': 'Only staff members can join as moderators'}, + status=status.HTTP_403_FORBIDDEN + ) + if user_type == 'moderator': + join_url = appointment.get_moderator_join_url(moderator_user=request.user) + else: + # Check if admin has joined and enabled participant joining + if not appointment.is_admin_join: + return Response( + {'error': 'Participants cannot join yet. Please wait for the admin to join and enable participant access.'}, + status=status.HTTP_403_FORBIDDEN + ) + join_url = appointment.get_participant_join_url(participant_user=request.user) + if not appointment.meeting_started_at and appointment.can_join_meeting(user_type): + appointment.start_meeting() + + return Response({ + 'appointment_id': str(appointment.id), + 'join_url': join_url, + 'room_id': appointment.jitsi_room_id, + 'user_type': user_type, + 'password': appointment.jitsi_meeting_password if user_type == 'participant' else None, + 'meeting_started': appointment.meeting_started_at is not None, + 'join_instructions': self._get_join_instructions(user_type), + }) + + def _has_join_permission(self, user, appointment): + if user.is_staff: + return True + + if appointment.email and appointment.email.lower() == user.email.lower(): + return True + + return False + + def _get_join_instructions(self, user_type): + if user_type == 'moderator': + return { + 'title': 'Join as Therapist/Moderator', + 'instructions': [ + 'Click the join link above', + 'You have full control over the meeting room', + 'You can mute participants, lock the room, and manage recordings', + 'Please join 15 minutes before the scheduled time', + 'Wait for your patient in the lobby if enabled' + ] + } + else: + return { + 'title': 'Join as Patient/Participant', + 'instructions': [ + 'Click the join link above', + 'Enter your name when prompted', + 'You may need to wait in the lobby until the therapist admits you', + 'Please join 5 minutes before the scheduled time', + 'Ensure you have a stable internet connection' + ] + } + + +class MeetingActionView(generics.GenericAPIView): + permission_classes = [IsAuthenticated, IsAdminUser] + serializer_class = MeetingActionSerializer + queryset = AppointmentRequest.objects.all() + lookup_field = 'pk' + + def post(self, request, pk): + appointment = self.get_object() + + if appointment.status != 'scheduled': + return Response( + {'error': 'Meeting actions only available for scheduled appointments'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + action = serializer.validated_data['action'] + + if action == 'start': + appointment.start_meeting() + message = 'Meeting started successfully' + + elif action == 'end': + appointment.end_meeting() + message = 'Meeting ended successfully' + + elif action == 'update_metadata': + metadata = serializer.validated_data.get('metadata', {}) + appointment.update_meeting_data(metadata) + message = 'Meeting metadata updated successfully' + + elif action == 'record': + recording_url = serializer.validated_data.get('recording_url', '') + if recording_url: + appointment.jitsi_recording_url = recording_url + appointment.save(update_fields=['jitsi_recording_url']) + message = 'Recording URL saved successfully' + else: + return Response( + {'error': 'Recording URL is required for record action'}, + status=status.HTTP_400_BAD_REQUEST + ) + + elif action == 'allow_participants': + appointment.is_admin_join = True + appointment.save(update_fields=['is_admin_join', 'updated_at']) + message = 'Participants can now join the meeting' + + elif action == 'disallow_participants': + appointment.is_admin_join = False + appointment.save(update_fields=['is_admin_join', 'updated_at']) + message = 'Participants are no longer allowed to join the meeting' + + return Response({ + 'appointment_id': str(appointment.id), + 'action': action, + 'message': message, + 'meeting_status': appointment.status, + 'started_at': appointment.meeting_started_at, + 'ended_at': appointment.meeting_ended_at, + 'is_admin_join': appointment.is_admin_join, + }) + + +class UpcomingMeetingsView(generics.ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = AppointmentDetailSerializer + + def get_queryset(self): + queryset = AppointmentRequest.objects.filter( + status='scheduled', + scheduled_datetime__gt=timezone.now() + ).order_by('scheduled_datetime') + + if not self.request.user.is_staff: + user_email = self.request.user.email.lower() + all_appointments = list(queryset) + matching_appointments = [ + apt for apt in all_appointments + if apt.email and apt.email.lower() == user_email + ] + appointment_ids = [apt.id for apt in matching_appointments] + queryset = queryset.filter(id__in=appointment_ids) + + return queryset + + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + queryset = self.get_queryset() + now = timezone.now() + + upcoming_meetings = [] + for meeting in queryset: + meeting_data = { + 'id': str(meeting.id), + 'title': f"Session with {meeting.full_name}", + 'start': meeting.scheduled_datetime.isoformat(), + 'end': (meeting.scheduled_datetime + timedelta(minutes=meeting.scheduled_duration)).isoformat(), + 'can_join': meeting.can_join_meeting('moderator' if request.user.is_staff else 'participant'), + 'has_video': meeting.has_jitsi_meeting, + 'status': meeting.get_meeting_status(), + } + upcoming_meetings.append(meeting_data) + + response.data = { + 'upcoming_meetings': upcoming_meetings, + 'total_upcoming': queryset.count(), + 'next_meeting': upcoming_meetings[0] if upcoming_meetings else None, + 'now': now.isoformat(), + } + + return response + + +class MeetingAnalyticsView(generics.RetrieveAPIView): + permission_classes = [IsAuthenticated, IsAdminUser] + queryset = AppointmentRequest.objects.all() + serializer_class = AppointmentDetailSerializer + lookup_field = 'pk' + + def retrieve(self, request, *args, **kwargs): + appointment = self.get_object() + + analytics = appointment.get_meeting_analytics() + + return Response({ + 'appointment_id': str(appointment.id), + 'patient_name': appointment.full_name, + 'analytics': analytics, + 'video_meeting_info': { + 'has_meeting': appointment.has_jitsi_meeting, + 'room_id': appointment.jitsi_room_id, + 'meeting_created': appointment.jitsi_meeting_created, + 'recording_available': bool(appointment.jitsi_recording_url), + }, + 'timeline': { + 'created': appointment.created_at.isoformat(), + 'scheduled': appointment.scheduled_datetime.isoformat() if appointment.scheduled_datetime else None, + 'started': appointment.meeting_started_at.isoformat() if appointment.meeting_started_at else None, + 'ended': appointment.meeting_ended_at.isoformat() if appointment.meeting_ended_at else None, + } + }) + + +class BulkMeetingActionsView(generics.GenericAPIView): + permission_classes = [IsAuthenticated, IsAdminUser] + + def post(self, request): + action = request.data.get('action') + appointment_ids = request.data.get('appointment_ids', []) + + if not action or not appointment_ids: + return Response( + {'error': 'Action and appointment_ids are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + valid_actions = ['create_jitsi_meetings', 'send_reminders', 'end_old_meetings'] + if action not in valid_actions: + return Response( + {'error': f'Invalid action. Must be one of: {valid_actions}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + appointments = AppointmentRequest.objects.filter( + id__in=appointment_ids, + status='scheduled' + ) + + results = [] + + if action == 'create_jitsi_meetings': + for appointment in appointments: + if not appointment.has_jitsi_meeting: + appointment.create_jitsi_meeting() + results.append({ + 'id': str(appointment.id), + 'action': 'meeting_created', + 'success': True, + 'message': f'Jitsi meeting created for {appointment.full_name}', + }) + else: + results.append({ + 'id': str(appointment.id), + 'action': 'already_created', + 'success': True, + 'message': f'Jitsi meeting already exists for {appointment.full_name}', + }) + + elif action == 'send_reminders': + for appointment in appointments: + if appointment.has_jitsi_meeting and appointment.meeting_in_future: + EmailService.send_meeting_reminder(appointment) + results.append({ + 'id': str(appointment.id), + 'action': 'reminder_sent', + 'success': True, + 'message': f'Reminder sent to {appointment.full_name}', + }) + + elif action == 'end_old_meetings': + for appointment in appointments: + if appointment.meeting_started_at and not appointment.meeting_ended_at: + scheduled_end = appointment.scheduled_datetime + timedelta(minutes=appointment.scheduled_duration) + buffer_end = scheduled_end + timedelta(minutes=30) + + if timezone.now() > buffer_end: + appointment.end_meeting() + results.append({ + 'id': str(appointment.id), + 'action': 'meeting_ended', + 'success': True, + 'message': f'Meeting ended for {appointment.full_name}', + }) + + return Response({ + 'action': action, + 'total_processed': len(results), + 'results': results, + }) + + @api_view(['GET']) @permission_classes([AllowAny]) def availability_overview(request): diff --git a/static/admin/img/heart-logo.png b/static/admin/img/heart-logo.png new file mode 100644 index 0000000..154fc0d Binary files /dev/null and b/static/admin/img/heart-logo.png differ diff --git a/static/admin/img/heart-logo.svg b/static/admin/img/heart-logo.svg new file mode 100644 index 0000000..f3b6806 --- /dev/null +++ b/static/admin/img/heart-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/staticfiles/admin/css/autocomplete.css b/staticfiles/admin/css/autocomplete.css new file mode 100644 index 0000000..7478c2c --- /dev/null +++ b/staticfiles/admin/css/autocomplete.css @@ -0,0 +1,279 @@ +select.admin-autocomplete { + width: 20em; +} + +.select2-container--admin-autocomplete.select2-container { + min-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single, +.select2-container--admin-autocomplete .select2-selection--multiple { + min-height: 30px; + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection, +.select2-container--admin-autocomplete.select2-container--open .select2-selection { + border-color: var(--body-quiet-color); + min-height: 30px; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-selection--single { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + line-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; +} + +.select2-container--admin-autocomplete .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--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: text; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { + list-style: none; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { + color: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { + color: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.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 { + float: right; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { + border: solid var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { + display: none; +} + +.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 { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.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 { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.select2-container--admin-autocomplete .select2-search--dropdown { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; +} + +.select2-container--admin-autocomplete .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option { + padding-left: 1em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; +} + +.select2-container--admin-autocomplete .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--admin-autocomplete .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--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} + +.errors .select2-selection { + border: 1px solid var(--error-fg); +} diff --git a/staticfiles/admin/css/base.css b/staticfiles/admin/css/base.css new file mode 100644 index 0000000..3791043 --- /dev/null +++ b/staticfiles/admin/css/base.css @@ -0,0 +1,1180 @@ +/* + DJANGO Admin styles +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-medium-color: #444; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); + --breadcrumbs-bg: #264b5d; + + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: var(--secondary); + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--secondary); + --button-hover-bg: #205067; + --default-button-bg: #205067; + --default-button-hover-bg: var(--secondary); + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + + color-scheme: light; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-size: 0.875rem; + font-family: var(--font-family-primary); + color: var(--body-fg); + background: var(--body-bg); +} + +/* LINKS */ + +a:link, a:visited { + color: var(--link-fg); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +a:focus, a:hover { + color: var(--link-hover-color); +} + +a:focus { + text-decoration: underline; +} + +a img { + border: none; +} + +a.section:link, a.section:visited { + color: var(--header-link-color); + text-decoration: none; +} + +a.section:focus, a.section:hover { + text-decoration: underline; +} + +/* GLOBAL DEFAULTS */ + +p, ol, ul, dl { + margin: .2em 0 .8em 0; +} + +p { + padding: 0; + line-height: 140%; +} + +h1,h2,h3,h4,h5 { + font-weight: bold; +} + +h1 { + margin: 0 0 20px; + font-weight: 300; + font-size: 1.25rem; +} + +h2 { + font-size: 1rem; + margin: 1em 0 .5em 0; +} + +h2.subhead { + font-weight: normal; + margin-top: 0; +} + +h3 { + font-size: 0.875rem; + margin: .8em 0 .3em 0; + color: var(--body-medium-color); + font-weight: bold; +} + +h4 { + font-size: 0.75rem; + margin: 1em 0 .8em 0; + padding-bottom: 3px; + color: var(--body-medium-color); +} + +h5 { + font-size: 0.625rem; + margin: 1.5em 0 .5em 0; + color: var(--body-quiet-color); + text-transform: uppercase; + letter-spacing: 1px; +} + +ul > li { + list-style-type: square; + padding: 1px 0; +} + +li ul { + margin-bottom: 0; +} + +li, dt, dd { + font-size: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +details summary { + cursor: pointer; +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +div.help ul { + margin-bottom: 0; +} + +.help-tooltip { + cursor: help; +} + +p img, h1 img, h2 img, h3 img, h4 img, td img { + vertical-align: middle; +} + +.quiet, a.quiet:link, a.quiet:visited { + color: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 500; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + font-weight: bold; +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +thead th.sorted .text { + padding-right: 42px; +} + +table thead th .text span { + padding: 8px 10px; + display: block; +} + +table thead th .text a { + display: block; + cursor: pointer; + padding: 8px 10px; +} + +table thead th .text a:focus, table thead th .text a:hover { + background: var(--selected-bg); +} + +thead th.sorted a.sortremove { + visibility: hidden; +} + +table thead th.sorted:hover a.sortremove { + visibility: visible; +} + +table thead th.sorted .sortoptions { + display: block; + padding: 9px 5px 0 5px; + float: right; + text-align: right; +} + +table thead th.sorted .sortpriority { + font-size: .8em; + min-width: 12px; + text-align: center; + vertical-align: 3px; + margin-left: 2px; + margin-right: 2px; +} + +table thead th.sorted .sortoptions a { + position: relative; + width: 14px; + height: 14px; + display: inline-block; + background: url(../img/sorting-icons.svg) 0 0 no-repeat; + background-size: 14px auto; +} + +table thead th.sorted .sortoptions a.sortremove { + background-position: 0 0; +} + +table thead th.sorted .sortoptions a.sortremove:after { + content: '\\'; + position: absolute; + top: -6px; + left: 3px; + font-weight: 200; + font-size: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +table thead th.sorted .sortoptions a.sortremove:focus, +table thead th.sorted .sortoptions a.sortremove:hover { + background-position: 0 -14px; +} + +table thead th.sorted .sortoptions a.ascending { + background-position: 0 -28px; +} + +table thead th.sorted .sortoptions a.ascending:focus, +table thead th.sorted .sortoptions a.ascending:hover { + background-position: 0 -42px; +} + +table thead th.sorted .sortoptions a.descending { + top: 1px; + background-position: 0 -56px; +} + +table thead th.sorted .sortoptions a.descending:focus, +table thead th.sorted .sortoptions a.descending:hover { + background-position: 0 -70px; +} + +/* FORM DEFAULTS */ + +input, textarea, select, .form-row p, form .button { + margin: 2px 0; + padding: 2px 3px; + vertical-align: middle; + font-family: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.form-row div.help { + padding: 2px 3px; +} + +textarea { + vertical-align: top; +} + +/* +Minifiers remove the default (text) "type" attribute from "input" HTML tags. +Add input:not([type]) to make the CSS stylesheet work the same. +*/ +input:not([type]), input[type=text], input[type=password], input[type=email], +input[type=url], input[type=number], input[type=tel], textarea, select, +.vTextField { + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +/* +Minifiers remove the default (text) "type" attribute from "input" HTML tags. +Add input:not([type]) to make the CSS stylesheet work the same. +*/ +input:not([type]):focus, input[type=text]:focus, input[type=password]:focus, +input[type=email]:focus, input[type=url]:focus, input[type=number]:focus, +input[type=tel]:focus, textarea:focus, select:focus, .vTextField:focus { + border-color: var(--body-quiet-color); +} + +select { + height: 1.875rem; +} + +select[multiple] { + /* Allow HTML size attribute to override the height in the rule above. */ + height: auto; + min-height: 150px; +} + +/* FORM BUTTONS */ + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +a.button { + padding: 4px 5px; +} + +.button:active, input[type=submit]:active, input[type=button]:active, +.button:focus, input[type=submit]:focus, input[type=button]:focus, +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.button.default:active, input[type=submit].default:active, +.button.default:focus, input[type=submit].default:focus, +.button.default:hover, input[type=submit].default:hover { + background: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.module p, .module ul, .module h3, .module h4, .module dl, .module pre { + padding-left: 10px; + padding-right: 10px; +} + +.module blockquote { + margin-left: 12px; +} + +.module ul, .module ol { + margin-left: 1.5em; +} + +.module h3 { + margin-top: .6em; +} + +.module h2, .module caption, .inline-group h2 { + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + text-align: left; + background: var(--header-bg); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.module table { + border-collapse: collapse; +} + +/* MESSAGES & ERRORS */ + +ul.messagelist { + padding: 0; + margin: 0; +} + +ul.messagelist li { + display: block; + font-weight: 400; + font-size: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +ul.errorlist li:first-child { + margin-top: 0; +} + +ul.errorlist li a { + color: inherit; + text-decoration: underline; +} + +td ul.errorlist { + margin: 0; + padding: 0; +} + +td ul.errorlist li { + margin: 0; +} + +.form-row.errors { + margin: 0; + border: none; + border-bottom: 1px solid var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* ACTION ICONS */ + +.viewlink, .inlineviewlink { + padding-left: 16px; + background: url(../img/icon-viewlink.svg) 0 1px no-repeat; +} + +.hidelink { + padding-left: 16px; + background: url(../img/icon-hidelink.svg) 0 1px no-repeat; +} + +.addlink { + padding-left: 16px; + background: url(../img/icon-addlink.svg) 0 1px no-repeat; +} + +.changelink, .inlinechangelink { + padding-left: 16px; + background: url(../img/icon-changelink.svg) 0 1px no-repeat; +} + +.deletelink { + padding-left: 16px; + background: url(../img/icon-deletelink.svg) 0 1px no-repeat; +} + +a.deletelink:link, a.deletelink:visited { + color: #CC3434; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url(../img/tooltag-arrowright.svg); +} + +.object-tools a.addlink { + background-image: url(../img/tooltag-add.svg); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#content { + padding: 20px 40px; +} + +.dashboard #content { + width: 600px; +} + +#content-main { + float: left; + width: 100%; +} + +#content-related { + float: right; + width: 260px; + position: relative; + margin-right: -300px; +} + +@media (forced-colors: active) { + #content-related { + border: 1px solid; + } +} + +/* COLUMN TYPES */ + +.colMS { + margin-right: 300px; +} + +.colSM { + margin-left: 300px; +} + +.colSM #content-related { + float: left; + margin-right: 0; + margin-left: -300px; +} + +.colSM #content-main { + float: right; +} + +.popup .colM { + width: auto; +} + +/* HEADER */ + +#header { + width: auto; + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 40px; + background: var(--header-bg); + color: var(--header-color); +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +@media (forced-colors: active) { + #header { + border-bottom: 1px solid; + } +} + +#branding { + display: flex; +} + +#site-name { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#site-name a:link, #site-name a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#content-related p { + padding-left: 16px; + padding-right: 16px; +} + +#content-related .actionlist { + padding: 0; + margin: 16px; +} + +#content-related .actionlist li { + line-height: 1.2; + margin-bottom: 10px; + padding-left: 18px; +} + +#content-related .module h2 { + background: none; + padding: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; + box-sizing: border-box; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.paginator input { + margin-left: auto; +} + +.base-svgs { + display: none; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/staticfiles/admin/css/changelists.css b/staticfiles/admin/css/changelists.css new file mode 100644 index 0000000..005b776 --- /dev/null +++ b/staticfiles/admin/css/changelists.css @@ -0,0 +1,343 @@ +/* CHANGELISTS */ + +#changelist { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#changelist .changelist-form-container { + flex: 1 1 auto; + min-width: 0; +} + +#changelist table { + width: 100%; +} + +.change-list .hiddenfields { display:none; } + +.change-list .filtered table { + border-right: none; +} + +.change-list .filtered { + min-height: 400px; +} + +.change-list .filtered .results, .change-list .filtered .paginator, +.filtered #toolbar, .filtered div.xfull { + width: auto; +} + +.change-list .filtered table tbody th { + padding-right: 1em; +} + +#changelist-form .results { + overflow-x: auto; + width: 100%; +} + +#changelist .toplinks { + border-bottom: 1px solid var(--hairline-color); +} + +#changelist .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* CHANGELIST TABLES */ + +#changelist table thead th { + padding: 0; + white-space: nowrap; + vertical-align: middle; +} + +#changelist table thead th.action-checkbox-column { + width: 1.5em; + text-align: center; +} + +#changelist table tbody td.action-checkbox { + text-align: center; +} + +#changelist table tfoot { + color: var(--body-quiet-color); +} + +/* TOOLBAR */ + +#toolbar { + padding: 8px 10px; + margin-bottom: 15px; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +#toolbar form input { + border-radius: 4px; + font-size: 0.875rem; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 1.1875rem; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 0.8125rem; + max-width: 100%; +} + +#toolbar #searchbar:focus { + border-color: var(--body-quiet-color); +} + +#toolbar form input[type="submit"] { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} + +#toolbar form input[type="submit"]:focus, +#toolbar form input[type="submit"]:hover { + border-color: var(--body-quiet-color); +} + +#changelist-search img { + vertical-align: middle; + margin-right: 4px; +} + +#changelist-search .help { + word-break: break-word; +} + +/* FILTER COLUMN */ + +#changelist-filter { + flex: 0 0 240px; + order: 1; + background: var(--darkened-bg); + border-left: none; + margin: 0 0 0 30px; +} + +@media (forced-colors: active) { + #changelist-filter { + border: 1px solid; + } +} + +#changelist-filter h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3, +#changelist-filter details summary { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-extra-actions { + font-size: 0.8125rem; + margin-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +@media (forced-colors: active) { + #changelist tbody tr.selected { + background-color: SelectedItem; + } + #changelist tbody tr:has(.action-select:checked) { + background-color: SelectedItem; + } +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/staticfiles/admin/css/dark_mode.css b/staticfiles/admin/css/dark_mode.css new file mode 100644 index 0000000..65b58d0 --- /dev/null +++ b/staticfiles/admin/css/dark_mode.css @@ -0,0 +1,130 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #d0d0d0; + --body-medium-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + + color-scheme: dark; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #d0d0d0; + --body-medium-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + + color-scheme: dark; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1.5rem; + width: 1.5rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} diff --git a/staticfiles/admin/css/dashboard.css b/staticfiles/admin/css/dashboard.css new file mode 100644 index 0000000..242b81a --- /dev/null +++ b/staticfiles/admin/css/dashboard.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.dashboard .module table th { + width: 100%; +} + +.dashboard .module table td { + white-space: nowrap; +} + +.dashboard .module table td a { + display: block; + padding-right: .6em; +} + +/* RECENT ACTIONS MODULE */ + +.module ul.actionlist { + margin-left: 0; +} + +ul.actionlist li { + list-style-type: none; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/staticfiles/admin/css/forms.css b/staticfiles/admin/css/forms.css new file mode 100644 index 0000000..c6ce788 --- /dev/null +++ b/staticfiles/admin/css/forms.css @@ -0,0 +1,498 @@ +@import url('widgets.css'); + +/* FORM ROWS */ + +.form-row { + overflow: hidden; + padding: 10px; + font-size: 0.8125rem; + border-bottom: 1px solid var(--hairline-color); +} + +.form-row img, .form-row input { + vertical-align: middle; +} + +.form-row label input[type="checkbox"] { + margin-top: 0; + vertical-align: 0; +} + +form .form-row p { + padding-left: 0; +} + +.flex-container { + display: flex; +} + +.form-multiline { + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { + margin: -2px 4px 0 0; + padding: 0; +} + +form ul.inline { + margin-left: 0; + padding: 0; +} + +form ul.inline li { + float: left; + padding-right: 7px; +} + +/* FIELDSETS */ + +fieldset .fieldset-heading, +fieldset .inline-heading, +:not(.inline-related) .collapse summary { + border: 1px solid var(--header-bg); + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + background: var(--header-bg); + color: var(--header-link-color); +} + +/* ALIGNED FIELDSETS */ + +.aligned label { + display: block; + padding: 4px 10px 0 0; + min-width: 160px; + width: 160px; + word-wrap: break-word; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.aligned ul label { + display: inline; + float: none; + width: auto; +} + +.aligned .form-row input { + margin-bottom: 0; +} + +.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { + width: 350px; +} + +form .aligned ul { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +form .aligned p.help:last-child, +form .aligned div.help:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +form .aligned input + p.help, +form .aligned textarea + p.help, +form .aligned select + p.help, +form .aligned input + div.help, +form .aligned textarea + div.help, +form .aligned select + div.help { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned select option:checked { + background-color: var(--selected-row); +} + +form .aligned ul li { + list-style: none; +} + +form .aligned table p { + margin-left: 0; + padding-left: 0; +} + +.aligned .vCheckboxLabel { + padding: 1px 0 0 5px; +} + +.aligned .vCheckboxLabel + p.help, +.aligned .vCheckboxLabel + div.help { + margin-top: -4px; +} + +.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { + width: 610px; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p.help, +form .wide ul.errorlist, +form .wide div.help { + padding-left: 50px; +} + +form div.help ul { + padding-left: 0; + margin-left: 0; +} + +.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { + width: 450px; +} + +/* COLLAPSIBLE FIELDSETS */ + +.collapse summary .fieldset-heading, +.collapse summary .inline-heading { + background: transparent; + border: none; + color: currentColor; + display: inline; + margin: 0; + padding: 0; +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* CUSTOM FORM FIELDS */ + +.vSelectMultipleField { + vertical-align: top; +} + +.vCheckboxField { + border: none; +} + +.vDateField, .vTimeField { + margin-right: 2px; + margin-bottom: 4px; +} + +.vDateField { + min-width: 6.85em; +} + +.vTimeField { + min-width: 4.7em; +} + +.vURLField { + width: 30em; +} + +.vLargeTextField, .vXMLLargeTextField { + width: 48em; +} + +.flatpages-flatpage #id_content { + height: 40.2em; +} + +.module table .vPositiveSmallIntegerField { + width: 2.2em; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* INLINES */ + +.inline-group { + padding: 0; + margin: 0 0 30px; +} + +.inline-group thead th { + padding: 8px 10px; +} + +.inline-group .aligned label { + width: 160px; +} + +.inline-related { + position: relative; +} + +.inline-related h4, +.inline-related:not(.tabular) .collapse summary { + margin: 0; + color: var(--body-medium-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-left-color: var(--darkened-bg); + border-right-color: var(--darkened-bg); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.last-related fieldset { + border: none; +} + +.inline-group .tabular tr.has_original td { + padding-top: 2em; +} + +.inline-group .tabular tr td.original { + padding: 2px 0 0 0; + width: 0; + _position: relative; +} + +.inline-group .tabular th.original { + width: 0px; + padding: 0; +} + +.inline-group .tabular td.original p { + position: absolute; + left: 0; + height: 1.1em; + padding: 2px 9px; + overflow: hidden; + font-size: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _width: 700px; +} + +.inline-group div.add-row, +.inline-group .tabular tr.add-row td { + color: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group div.add-row a, +.inline-group .tabular tr.add-row td a { + font-size: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + background-image: url(../img/search.svg); +} + +form .related-widget-wrapper ul { + display: inline-block; + margin-left: 0; + padding-left: 0; +} + +.clearable-file-input input { + margin-top: 0; +} diff --git a/staticfiles/admin/css/login.css b/staticfiles/admin/css/login.css new file mode 100644 index 0000000..805a34b --- /dev/null +++ b/staticfiles/admin/css/login.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/staticfiles/admin/css/nav_sidebar.css b/staticfiles/admin/css/nav_sidebar.css new file mode 100644 index 0000000..7eb0de9 --- /dev/null +++ b/staticfiles/admin/css/nav_sidebar.css @@ -0,0 +1,150 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +@media (forced-colors: active) { + #nav-sidebar .current-model { + background-color: SelectedItem; + } +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/staticfiles/admin/css/responsive.css b/staticfiles/admin/css/responsive.css new file mode 100644 index 0000000..f0fcade --- /dev/null +++ b/staticfiles/admin/css/responsive.css @@ -0,0 +1,904 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #site-name { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 1rem; + } + + /* + Minifiers remove the default (text) "type" attribute from "input" HTML + tags. Add input:not([type]) to make the CSS stylesheet work the same. + */ + .form-row input:not([type]), + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 1rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter input { + width: 100%; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector-chooseall, .selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + padding: 0 2px; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #site-name { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content { + padding: 15px; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + .flex-container.checkbox-row { + flex-flow: row; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + .aligned label { + width: 100%; + min-width: auto; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + flex: 1 0 auto; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + /* Selector */ + + .selector { + flex-direction: column; + gap: 10px 0; + } + + .selector-available, .selector-chosen { + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: flex; + width: 60px; + height: 30px; + padding: 0 2px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + :enabled.selector-remove:focus, :enabled.selector-remove:hover { + background-position: 0 -24px; + } + + .selector-add { + background-position: 0 -48px; + } + + :enabled.selector-add:focus, :enabled.selector-add:hover { + background-position: 0 -72px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/staticfiles/admin/css/responsive_rtl.css b/staticfiles/admin/css/responsive_rtl.css new file mode 100644 index 0000000..5e8f5c5 --- /dev/null +++ b/staticfiles/admin/css/responsive_rtl.css @@ -0,0 +1,89 @@ +/* TABLETS */ + +@media (max-width: 1024px) { + [dir="rtl"] .colMS { + margin-right: 0; + } + + [dir="rtl"] #user-tools { + text-align: right; + } + + [dir="rtl"] #changelist .actions label { + padding-left: 10px; + padding-right: 0; + } + + [dir="rtl"] #changelist .actions select { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .change-list .filtered .results, + [dir="rtl"] .change-list .filtered .paginator, + [dir="rtl"] .filtered #toolbar, + [dir="rtl"] .filtered div.xfull, + [dir="rtl"] .filtered .actions, + [dir="rtl"] #changelist-filter { + margin-left: 0; + } + + [dir="rtl"] .inline-group div.add-row a, + [dir="rtl"] .inline-group .tabular tr.add-row td a { + padding: 8px 26px 8px 10px; + background-position: calc(100% - 8px) 9px; + } + + [dir="rtl"] .object-tools li { + float: right; + } + + [dir="rtl"] .object-tools li + li { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .dashboard .module table td a { + padding-left: 0; + padding-right: 16px; + } +} + +/* MOBILE */ + +@media (max-width: 767px) { + [dir="rtl"] .aligned .related-lookup, + [dir="rtl"] .aligned .datetimeshortcuts { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { + margin-right: 0; + } + + [dir="rtl"] #changelist-filter { + margin-left: 0; + margin-right: 0; + } + [dir="rtl"] .aligned .vCheckboxLabel { + padding: 1px 5px 0 0; + } + + [dir="rtl"] .selector-remove { + background-position: 0 0; + } + + [dir="rtl"] :enabled.selector-remove:focus, :enabled.selector-remove:hover { + background-position: 0 -24px; + } + + [dir="rtl"] .selector-add { + background-position: 0 -48px; + } + + [dir="rtl"] :enabled.selector-add:focus, :enabled.selector-add:hover { + background-position: 0 -72px; + } +} diff --git a/staticfiles/admin/css/rtl.css b/staticfiles/admin/css/rtl.css new file mode 100644 index 0000000..a2556d0 --- /dev/null +++ b/staticfiles/admin/css/rtl.css @@ -0,0 +1,293 @@ +/* GLOBAL */ + +th { + text-align: right; +} + +.module h2, .module caption { + text-align: right; +} + +.module ul, .module ol { + margin-left: 0; + margin-right: 1.5em; +} + +.viewlink, .addlink, .changelink, .hidelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.deletelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.object-tools { + float: left; +} + +thead th:first-child, +tfoot td:first-child { + border-left: none; +} + +/* LAYOUT */ + +#user-tools { + right: auto; + left: 0; + text-align: left; +} + +div.breadcrumbs { + text-align: right; +} + +#content-main { + float: right; +} + +#content-related { + float: left; + margin-left: -300px; + margin-right: auto; +} + +.colMS { + margin-left: 300px; + margin-right: 0; +} + +/* SORTABLE TABLES */ + +table thead th.sorted .sortoptions { + float: left; +} + +thead th.sorted .text { + padding-right: 0; + padding-left: 42px; +} + +/* dashboard styles */ + +.dashboard .module table td a { + padding-left: .6em; + padding-right: 16px; +} + +/* changelists styles */ + +.change-list .filtered table { + border-left: none; + border-right: 0px none; +} + +#changelist-filter { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 30px; +} + +#changelist-filter li.selected { + border-left: none; + padding-left: 10px; + margin-left: 0; + border-right: 5px solid var(--hairline-color); + padding-right: 10px; + margin-right: -15px; +} + +#changelist table tbody td:first-child, #changelist table tbody th:first-child { + border-right: none; + border-left: none; +} + +.paginator .end { + margin-left: 6px; + margin-right: 0; +} + +.paginator input { + margin-left: 0; + margin-right: auto; +} + +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; +} + +.submit-row a.deletelink { + margin-left: 0; + margin-right: auto; +} + +.vDateField, .vTimeField { + margin-left: 2px; +} + +.aligned .form-row input { + margin-left: 5px; +} + +form .aligned ul { + margin-right: 163px; + padding-right: 10px; + margin-left: 0; + padding-left: 0; +} + +form ul.inline li { + float: right; + padding-right: 0; + padding-left: 7px; +} + +form .aligned p.help, +form .aligned div.help { + margin-left: 0; + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, +form .wide ul.errorlist, +form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +.submit-row { + text-align: right; +} + +fieldset .fieldBox { + margin-left: 20px; + margin-right: 0; +} + +.errorlist li { + background-position: 100% 12px; + padding: 0; +} + +.errornote { + background-position: 100% 12px; + padding: 10px 12px; +} + +/* WIDGETS */ + +.calendarnav-previous { + top: 0; + left: auto; + right: 10px; + background: url(../img/calendar-icons.svg) 0 -15px no-repeat; +} + +.calendarnav-next { + top: 0; + right: auto; + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendar caption, .calendarbox h2 { + text-align: center; +} + +.selector { + float: right; +} + +.selector .selector-filter { + text-align: right; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; + background-size: 24px auto; +} + +:enabled.selector-add:focus, :enabled.selector-add:hover { + background-position: 0 -120px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -144px no-repeat; + background-size: 24px auto; +} + +:enabled.selector-remove:focus, :enabled.selector-remove:hover { + background-position: 0 -168px; +} + +.selector-chooseall { + background: url(../img/selector-icons.svg) right -128px no-repeat; +} + +:enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover { + background-position: 100% -144px; +} + +.selector-clearall { + background: url(../img/selector-icons.svg) 0 -160px no-repeat; +} + +:enabled.selector-clearall:focus, :enabled.selector-clearall:hover { + background-position: 0 -176px; +} + +.inline-deletelink { + float: left; +} + +form .form-row p.datetime { + overflow: hidden; +} + +.related-widget-wrapper { + float: right; +} + +/* MISC */ + +.inline-related h2, .inline-group h2 { + text-align: right +} + +.inline-related h3 span.delete { + padding-right: 20px; + padding-left: inherit; + left: 10px; + right: inherit; + float:left; +} + +.inline-related h3 span.delete label { + margin-left: inherit; + margin-right: 2px; +} + +.inline-group .tabular td.original p { + right: 0; +} + +.selector .selector-chooser { + margin: 0; +} diff --git a/staticfiles/admin/css/unusable_password_field.css b/staticfiles/admin/css/unusable_password_field.css new file mode 100644 index 0000000..d46eb03 --- /dev/null +++ b/staticfiles/admin/css/unusable_password_field.css @@ -0,0 +1,19 @@ +/* Hide warnings fields if usable password is selected */ +form:has(#id_usable_password input[value="true"]:checked) .messagelist { + display: none; +} + +/* Hide password fields if unusable password is selected */ +form:has(#id_usable_password input[value="false"]:checked) .field-password1, +form:has(#id_usable_password input[value="false"]:checked) .field-password2 { + display: none; +} + +/* Select appropriate submit button */ +form:has(#id_usable_password input[value="true"]:checked) input[type="submit"].unset-password { + display: none; +} + +form:has(#id_usable_password input[value="false"]:checked) input[type="submit"].set-password { + display: none; +} diff --git a/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md b/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md new file mode 100644 index 0000000..8cb8a2b --- /dev/null +++ b/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/staticfiles/admin/css/vendor/select2/select2.css b/staticfiles/admin/css/vendor/select2/select2.css new file mode 100644 index 0000000..750b320 --- /dev/null +++ b/staticfiles/admin/css/vendor/select2/select2.css @@ -0,0 +1,481 @@ +.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, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 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, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 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, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 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, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 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: white; + 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: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/staticfiles/admin/css/vendor/select2/select2.min.css b/staticfiles/admin/css/vendor/select2/select2.min.css new file mode 100644 index 0000000..7c18ad5 --- /dev/null +++ b/staticfiles/admin/css/vendor/select2/select2.min.css @@ -0,0 +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} diff --git a/staticfiles/admin/css/widgets.css b/staticfiles/admin/css/widgets.css new file mode 100644 index 0000000..a5f615a --- /dev/null +++ b/staticfiles/admin/css/widgets.css @@ -0,0 +1,613 @@ +/* SELECTOR (FILTER INTERFACE) */ + +.selector { + display: flex; + flex: 1; + gap: 0 10px; +} + +.selector select { + height: 17.2em; + flex: 1 0 auto; + overflow: scroll; + width: 100%; +} + +.selector-available, .selector-chosen { + display: flex; + flex-direction: column; + flex: 1 1; +} + +.selector-available-title, .selector-chosen-title { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.selector .helptext { + font-size: 0.6875rem; +} + +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + +.selector-chosen-title { + background: var(--secondary); + color: var(--header-link-color); + padding: 8px; +} + +.selector-chosen-title label { + color: var(--header-link-color); + width: 100%; +} + +.selector-available-title { + background: var(--darkened-bg); + color: var(--body-quiet-color); + padding: 8px; +} + +.selector-available-title label { + width: 100%; +} + +.selector .selector-filter { + border: 1px solid var(--border-color); + border-width: 0 1px; + padding: 8px; + color: var(--body-quiet-color); + font-size: 0.625rem; + margin: 0; + text-align: left; + display: flex; + gap: 8px; +} + +.selector .selector-filter label, +.inline-group .aligned .selector .selector-filter label { + float: left; + margin: 7px 0 0; + width: 18px; + height: 18px; + padding: 0; + overflow: hidden; + line-height: 1; + min-width: auto; +} + +.selector-filter input { + flex-grow: 1; +} + +.selector ul.selector-chooser { + align-self: center; + width: 30px; + background-color: var(--selected-bg); + border-radius: 10px; + margin: 0; + padding: 0; + transform: translateY(-17px); +} + +.selector-chooser li { + margin: 0; + padding: 3px; + list-style-type: none; +} + +.selector select { + padding: 0 10px; + margin: 0 0 10px; + border-radius: 0 0 4px 4px; +} +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} + +.selector-add, .selector-remove { + width: 24px; + height: 24px; + display: block; + text-indent: -3000px; + overflow: hidden; + cursor: default; + opacity: 0.55; + border: none; +} + +:enabled.selector-add, :enabled.selector-remove { + opacity: 1; +} + +:enabled.selector-add:hover, :enabled.selector-remove:hover { + cursor: pointer; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -144px no-repeat; + background-size: 24px auto; +} + +:enabled.selector-add:focus, :enabled.selector-add:hover { + background-position: 0 -168px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; + background-size: 24px auto; +} + +:enabled.selector-remove:focus, :enabled.selector-remove:hover { + background-position: 0 -120px; +} + +.selector-chooseall, .selector-clearall { + display: inline-block; + height: 16px; + text-align: left; + margin: 0 auto; + overflow: hidden; + font-weight: bold; + line-height: 16px; + color: var(--body-quiet-color); + text-decoration: none; + opacity: 0.55; + border: none; +} + +:enabled.selector-chooseall:focus, :enabled.selector-clearall:focus, +:enabled.selector-chooseall:hover, :enabled.selector-clearall:hover { + color: var(--link-fg); +} + +:enabled.selector-chooseall, :enabled.selector-clearall { + opacity: 1; +} + +:enabled.selector-chooseall:hover, :enabled.selector-clearall:hover { + cursor: pointer; +} + +.selector-chooseall { + padding: 0 18px 0 0; + background: url(../img/selector-icons.svg) right -160px no-repeat; + cursor: default; +} + +:enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover { + background-position: 100% -176px; +} + +.selector-clearall { + padding: 0 0 0 18px; + background: url(../img/selector-icons.svg) 0 -128px no-repeat; + cursor: default; +} + +:enabled.selector-clearall:focus, :enabled.selector-clearall:hover { + background-position: 0 -144px; +} + +/* STACKED SELECTORS */ + +.stacked { + float: left; + width: 490px; + display: block; +} + +.stacked select { + width: 480px; + height: 10.1em; +} + +.stacked .selector-available, .stacked .selector-chosen { + width: 480px; +} + +.stacked .selector-available { + margin-bottom: 0; +} + +.stacked .selector-available input { + width: 422px; +} + +.stacked ul.selector-chooser { + display: flex; + height: 30px; + width: 64px; + margin: 0 0 10px 40%; + background-color: #eee; + border-radius: 10px; + transform: none; +} + +.stacked .selector-chooser li { + float: left; + padding: 3px 3px 3px 5px; +} + +.stacked .selector-chooseall, .stacked .selector-clearall { + display: none; +} + +.stacked .selector-add { + background: url(../img/selector-icons.svg) 0 -48px no-repeat; + background-size: 24px auto; + cursor: default; +} + +.stacked :enabled.selector-add { + background-position: 0 -48px; + cursor: pointer; +} + +.stacked :enabled.selector-add:focus, .stacked :enabled.selector-add:hover { + background-position: 0 -72px; + cursor: pointer; +} + +.stacked .selector-remove { + background: url(../img/selector-icons.svg) 0 0 no-repeat; + background-size: 24px auto; + cursor: default; +} + +.stacked :enabled.selector-remove { + background-position: 0 0px; + cursor: pointer; +} + +.stacked :enabled.selector-remove:focus, .stacked :enabled.selector-remove:hover { + background-position: 0 -24px; + cursor: pointer; +} + +.selector .help-icon { + background: url(../img/icon-unknown.svg) 0 0 no-repeat; + display: inline-block; + vertical-align: middle; + margin: -2px 0 0 2px; + width: 13px; + height: 13px; +} + +.selector .selector-chosen .help-icon { + background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; +} + +.selector .search-label-icon { + background: url(../img/search.svg) 0 0 no-repeat; + display: inline-block; + height: 1.125rem; + width: 1.125rem; +} + +/* DATE AND TIME */ + +p.datetime { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +.datetime span { + white-space: nowrap; + font-weight: normal; + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + margin-left: 5px; + margin-bottom: 4px; +} + +table p.datetime { + font-size: 0.6875rem; + margin-left: 0; + padding-left: 0; +} + +.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { + position: relative; + display: inline-block; + vertical-align: middle; + height: 24px; + width: 24px; + overflow: hidden; +} + +.datetimeshortcuts .clock-icon { + background: url(../img/icon-clock.svg) 0 0 no-repeat; + background-size: 24px auto; +} + +.datetimeshortcuts a:focus .clock-icon, +.datetimeshortcuts a:hover .clock-icon { + background-position: 0 -24px; +} + +.datetimeshortcuts .date-icon { + background: url(../img/icon-calendar.svg) 0 0 no-repeat; + background-size: 24px auto; + top: -1px; +} + +.datetimeshortcuts a:focus .date-icon, +.datetimeshortcuts a:hover .date-icon { + background-position: 0 -24px; +} + +.timezonewarning { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +/* URL */ + +p.url { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.url a { + font-weight: normal; +} + +/* FILE UPLOADS */ + +p.file-upload { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.file-upload a { + font-weight: normal; +} + +.file-upload .deletelink { + margin-left: 5px; +} + +span.clearable-file-input label { + color: var(--body-fg); + font-size: 0.6875rem; + display: inline; + float: none; +} + +/* CALENDARS & CLOCKS */ + +.calendarbox, .clockbox { + margin: 5px auto; + font-size: 0.75rem; + width: 19em; + text-align: center; + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + position: relative; +} + +.clockbox { + width: auto; +} + +.calendar { + margin: 0; + padding: 0; +} + +.calendar table { + margin: 0; + padding: 0; + border-collapse: collapse; + background: white; + width: 100%; +} + +.calendar caption, .calendarbox h2 { + margin: 0; + text-align: center; + border-top: none; + font-weight: 700; + font-size: 0.75rem; + color: #333; + background: var(--accent); +} + +.calendar th { + padding: 8px 5px; + background: var(--darkened-bg); + border-bottom: 1px solid var(--border-color); + font-weight: 400; + font-size: 0.75rem; + text-align: center; + color: var(--body-quiet-color); +} + +.calendar td { + font-weight: 400; + font-size: 0.75rem; + text-align: center; + padding: 0; + border-top: 1px solid var(--hairline-color); + border-bottom: none; +} + +.calendar td.selected a { + background: var(--secondary); + color: var(--button-fg); +} + +.calendar td.nonday { + background: var(--darkened-bg); +} + +.calendar td.today a { + font-weight: 700; +} + +.calendar td a, .timelist a { + display: block; + font-weight: 400; + padding: 6px; + text-decoration: none; + color: var(--body-quiet-color); +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: var(--primary); + color: white; +} + +.calendar td a:active, .timelist a:active { + background: var(--header-bg); + color: white; +} + +.calendarnav { + font-size: 0.625rem; + text-align: center; + color: #ccc; + margin: 0; + padding: 1px 3px; +} + +.calendarnav a:link, #calendarnav a:visited, +#calendarnav a:focus, #calendarnav a:hover { + color: var(--body-quiet-color); +} + +.calendar-shortcuts { + background: var(--body-bg); + color: var(--body-quiet-color); + font-size: 0.6875rem; + line-height: 0.6875rem; + border-top: 1px solid var(--hairline-color); + padding: 8px 0; +} + +.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + display: block; + position: absolute; + top: 8px; + width: 15px; + height: 15px; + text-indent: -9999px; + padding: 0; +} + +.calendarnav-previous { + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarnav-next { + right: 10px; + background: url(../img/calendar-icons.svg) 0 -15px no-repeat; +} + +.calendar-cancel { + margin: 0; + padding: 4px 0; + font-size: 0.75rem; + background: var(--close-button-bg); + border-top: 1px solid var(--border-color); + color: var(--button-fg); +} + +.calendar-cancel:focus, .calendar-cancel:hover { + background: var(--close-button-hover-bg); +} + +.calendar-cancel a { + color: var(--button-fg); + display: block; +} + +ul.timelist, .timelist li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.timelist a { + padding: 2px; +} + +/* EDIT INLINE */ + +.inline-deletelink { + float: right; + text-indent: -9999px; + background: url(../img/inline-delete.svg) 0 0 no-repeat; + width: 1.5rem; + height: 1.5rem; + border: 0px none; + margin-bottom: .25rem; +} + +.inline-deletelink:focus, .inline-deletelink:hover { + cursor: pointer; +} + +/* RELATED WIDGET WRAPPER */ +.related-widget-wrapper { + display: flex; + gap: 0 10px; + flex-grow: 1; + flex-wrap: wrap; + margin-bottom: 5px; +} + +.related-widget-wrapper-link { + opacity: .6; + filter: grayscale(1); +} + +.related-widget-wrapper-link:link { + opacity: 1; + filter: grayscale(0); +} + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/staticfiles/admin/img/LICENSE b/staticfiles/admin/img/LICENSE new file mode 100644 index 0000000..a4faaa1 --- /dev/null +++ b/staticfiles/admin/img/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Code Charm Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/staticfiles/admin/img/README.txt b/staticfiles/admin/img/README.txt new file mode 100644 index 0000000..bf81f35 --- /dev/null +++ b/staticfiles/admin/img/README.txt @@ -0,0 +1,7 @@ +All icons are taken from Font Awesome (https://fontawesome.com/) project. +The Font Awesome font is licensed under the SIL OFL 1.1: +- https://scripts.sil.org/OFL + +SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG +Font-Awesome-SVG-PNG is licensed under the MIT license (see file license +in current folder). diff --git a/staticfiles/admin/img/calendar-icons.svg b/staticfiles/admin/img/calendar-icons.svg new file mode 100644 index 0000000..04c0274 --- /dev/null +++ b/staticfiles/admin/img/calendar-icons.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + diff --git a/staticfiles/admin/img/gis/move_vertex_off.svg b/staticfiles/admin/img/gis/move_vertex_off.svg new file mode 100644 index 0000000..228854f --- /dev/null +++ b/staticfiles/admin/img/gis/move_vertex_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/staticfiles/admin/img/gis/move_vertex_on.svg b/staticfiles/admin/img/gis/move_vertex_on.svg new file mode 100644 index 0000000..96b87fd --- /dev/null +++ b/staticfiles/admin/img/gis/move_vertex_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/staticfiles/admin/img/icon-addlink.svg b/staticfiles/admin/img/icon-addlink.svg new file mode 100644 index 0000000..8d5c6a3 --- /dev/null +++ b/staticfiles/admin/img/icon-addlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-alert.svg b/staticfiles/admin/img/icon-alert.svg new file mode 100644 index 0000000..e51ea83 --- /dev/null +++ b/staticfiles/admin/img/icon-alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-calendar.svg b/staticfiles/admin/img/icon-calendar.svg new file mode 100644 index 0000000..97910a9 --- /dev/null +++ b/staticfiles/admin/img/icon-calendar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/staticfiles/admin/img/icon-changelink.svg b/staticfiles/admin/img/icon-changelink.svg new file mode 100644 index 0000000..592b093 --- /dev/null +++ b/staticfiles/admin/img/icon-changelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-clock.svg b/staticfiles/admin/img/icon-clock.svg new file mode 100644 index 0000000..bf9985d --- /dev/null +++ b/staticfiles/admin/img/icon-clock.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/staticfiles/admin/img/icon-deletelink.svg b/staticfiles/admin/img/icon-deletelink.svg new file mode 100644 index 0000000..4059b15 --- /dev/null +++ b/staticfiles/admin/img/icon-deletelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-hidelink.svg b/staticfiles/admin/img/icon-hidelink.svg new file mode 100644 index 0000000..2a8b404 --- /dev/null +++ b/staticfiles/admin/img/icon-hidelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-no.svg b/staticfiles/admin/img/icon-no.svg new file mode 100644 index 0000000..2e0d383 --- /dev/null +++ b/staticfiles/admin/img/icon-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-unknown-alt.svg b/staticfiles/admin/img/icon-unknown-alt.svg new file mode 100644 index 0000000..1c6b99f --- /dev/null +++ b/staticfiles/admin/img/icon-unknown-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-unknown.svg b/staticfiles/admin/img/icon-unknown.svg new file mode 100644 index 0000000..50b4f97 --- /dev/null +++ b/staticfiles/admin/img/icon-unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-viewlink.svg b/staticfiles/admin/img/icon-viewlink.svg new file mode 100644 index 0000000..a1ca1d3 --- /dev/null +++ b/staticfiles/admin/img/icon-viewlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-yes.svg b/staticfiles/admin/img/icon-yes.svg new file mode 100644 index 0000000..5883d87 --- /dev/null +++ b/staticfiles/admin/img/icon-yes.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/inline-delete.svg b/staticfiles/admin/img/inline-delete.svg new file mode 100644 index 0000000..8751150 --- /dev/null +++ b/staticfiles/admin/img/inline-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/search.svg b/staticfiles/admin/img/search.svg new file mode 100644 index 0000000..c8c69b2 --- /dev/null +++ b/staticfiles/admin/img/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/selector-icons.svg b/staticfiles/admin/img/selector-icons.svg new file mode 100644 index 0000000..926b8e2 --- /dev/null +++ b/staticfiles/admin/img/selector-icons.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/staticfiles/admin/img/sorting-icons.svg b/staticfiles/admin/img/sorting-icons.svg new file mode 100644 index 0000000..7c31ec9 --- /dev/null +++ b/staticfiles/admin/img/sorting-icons.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/staticfiles/admin/img/tooltag-add.svg b/staticfiles/admin/img/tooltag-add.svg new file mode 100644 index 0000000..1ca64ae --- /dev/null +++ b/staticfiles/admin/img/tooltag-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/tooltag-arrowright.svg b/staticfiles/admin/img/tooltag-arrowright.svg new file mode 100644 index 0000000..b664d61 --- /dev/null +++ b/staticfiles/admin/img/tooltag-arrowright.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/js/SelectBox.js b/staticfiles/admin/js/SelectBox.js new file mode 100644 index 0000000..3db4ec7 --- /dev/null +++ b/staticfiles/admin/js/SelectBox.js @@ -0,0 +1,116 @@ +'use strict'; +{ + const SelectBox = { + cache: {}, + init: function(id) { + const box = document.getElementById(id); + SelectBox.cache[id] = []; + const cache = SelectBox.cache[id]; + for (const node of box.options) { + cache.push({value: node.value, text: node.text, displayed: 1}); + } + }, + redisplay: function(id) { + // Repopulate HTML select box from cache + const box = document.getElementById(id); + const scroll_value_from_top = box.scrollTop; + box.innerHTML = ''; + for (const node of SelectBox.cache[id]) { + if (node.displayed) { + const new_option = new Option(node.text, node.value, false, false); + // Shows a tooltip when hovering over the option + new_option.title = node.text; + box.appendChild(new_option); + } + } + box.scrollTop = scroll_value_from_top; + }, + filter: function(id, text) { + // Redisplay the HTML select box, displaying only the choices containing ALL + // the words in text. (It's an AND search.) + const tokens = text.toLowerCase().split(/\s+/); + for (const node of SelectBox.cache[id]) { + node.displayed = 1; + const node_text = node.text.toLowerCase(); + for (const token of tokens) { + if (!node_text.includes(token)) { + node.displayed = 0; + break; // Once the first token isn't found we're done + } + } + } + SelectBox.redisplay(id); + }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, + delete_from_cache: function(id, value) { + let delete_index = null; + const cache = SelectBox.cache[id]; + for (const [i, node] of cache.entries()) { + if (node.value === value) { + delete_index = i; + break; + } + } + cache.splice(delete_index, 1); + }, + add_to_cache: function(id, option) { + SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + }, + cache_contains: function(id, value) { + // Check if an item is contained in the cache + for (const node of SelectBox.cache[id]) { + if (node.value === value) { + return true; + } + } + return false; + }, + move: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (option.selected && SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + move_all: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + sort: function(id) { + SelectBox.cache[id].sort(function(a, b) { + a = a.text.toLowerCase(); + b = b.text.toLowerCase(); + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } ); + }, + select_all: function(id) { + const box = document.getElementById(id); + for (const option of box.options) { + option.selected = true; + } + } + }; + window.SelectBox = SelectBox; +} diff --git a/staticfiles/admin/js/SelectFilter2.js b/staticfiles/admin/js/SelectFilter2.js new file mode 100644 index 0000000..970b511 --- /dev/null +++ b/staticfiles/admin/js/SelectFilter2.js @@ -0,0 +1,311 @@ +/*global SelectBox, gettext, ngettext, interpolate, quickElement, SelectFilter*/ +/* +SelectFilter2 - Turns a multiple-select box into a filter interface. + +Requires core.js and SelectBox.js. +*/ +'use strict'; +{ + window.SelectFilter = { + init: function(field_id, field_name, is_stacked) { + if (field_id.match(/__prefix__/)) { + // Don't initialize on empty forms. + return; + } + const from_box = document.getElementById(field_id); + from_box.id += '_from'; // change its ID + from_box.className = 'filtered'; + from_box.setAttribute('aria-labelledby', field_id + '_from_title'); + + for (const p of from_box.parentNode.getElementsByTagName('p')) { + if (p.classList.contains("info")) { + // Remove

, because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //

or
+ const selector_div = quickElement('div', from_box.parentNode); + // Make sure the selector div is at the beginning so that the + // add link would be displayed to the right of the widget. + from_box.parentNode.prepend(selector_div); + selector_div.className = is_stacked ? 'selector stacked' : 'selector'; + + //
+ const selector_available = quickElement('div', selector_div); + selector_available.className = 'selector-available'; + const selector_available_title = quickElement('div', selector_available); + selector_available_title.id = field_id + '_from_title'; + selector_available_title.className = 'selector-available-title'; + quickElement('label', selector_available_title, interpolate(gettext('Available %s') + ' ', [field_name]), 'for', field_id + '_from'); + quickElement( + 'p', + selector_available_title, + interpolate(gettext('Choose %s by selecting them and then select the "Choose" arrow button.'), [field_name]), + 'class', 'helptext' + ); + + const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); + filter_p.className = 'selector-filter'; + + const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); + + quickElement( + 'span', search_filter_label, '', + 'class', 'help-tooltip search-label-icon', + 'aria-label', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) + ); + + filter_p.appendChild(document.createTextNode(' ')); + + const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_input.id = field_id + '_input'; + + selector_available.appendChild(from_box); + const choose_all = quickElement( + 'button', + selector_available, + interpolate(gettext('Choose all %s'), [field_name]), + 'id', field_id + '_add_all', + 'class', 'selector-chooseall', + 'type', 'button' + ); + + //