Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
7be9310a96 v1.1 2025-11-23 01:09:28 +00:00
178 changed files with 34264 additions and 152 deletions

View File

@ -58,9 +58,12 @@ INSTALLED_APPS = [
'core',
]
AUTH_USER_MODEL = 'core.User'
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'core.middleware.UndoSessionCleanup',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@ -171,3 +174,9 @@ if EMAIL_USE_SSL:
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Undo timeout duration in seconds (default: 30s). Affects both delete and update operations.
UNDO_TIMEOUT = int(os.getenv("UNDO_TIMEOUT", 30))
# Toast Notification Position
TOAST_POSITION = os.getenv("TOAST_POSITION", "top-end")

Binary file not shown.

Binary file not shown.

Binary file not shown.

44
core/forms.py Normal file
View File

@ -0,0 +1,44 @@
from django import forms
from .models import Customer, User, Opportunity, Lead
from django.core.validators import validate_email
class CustomerForm(forms.ModelForm):
class Meta:
model = Customer
fields = [
'name',
'email',
'phone',
'status',
'owner',
]
widgets = {
'status': forms.Select(attrs={'class': 'form-select'}),
'owner': forms.Select(attrs={'class': 'form-select'}),
}
def clean_email(self):
email = self.cleaned_data.get('email')
validate_email(email)
return email
class OpportunityForm(forms.ModelForm):
class Meta:
model = Opportunity
fields = [
'name',
'lead',
'value',
'stage',
'probability',
'close_date',
]
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'value': forms.NumberInput(attrs={'class': 'form-control'}),
'probability': forms.NumberInput(attrs={'class': 'form-control'}),
'stage': forms.Select(attrs={'class': 'form-select'}),
'lead': forms.Select(attrs={'class': 'form-select'}),
'close_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
}

View File

@ -0,0 +1,101 @@
import random
from faker import Faker
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from core.models import Customer, Lead, Opportunity, ContactHistory, Tenant
User = get_user_model()
class Command(BaseCommand):
help = 'Seeds the database with sample customers, leads, opportunities, and contact histories.'
def handle(self, *args, **options):
self.stdout.write("Starting database seeding...")
faker = Faker()
# Clean up existing data
Tenant.objects.all().delete()
User.objects.filter(is_superuser=False).delete()
Customer.objects.all().delete()
Lead.objects.all().delete()
Opportunity.objects.all().delete()
ContactHistory.objects.all().delete()
self.stdout.write("Cleared existing data.")
# Create sample tenants
tenants = []
for _ in range(3):
tenant = Tenant.objects.create(name=faker.company())
tenants.append(tenant)
self.stdout.write(f"Created {len(tenants)} sample tenants.")
# Create sample users
users = []
for _ in range(5):
email = faker.email()
# Avoid creating a user that might already exist
if not User.objects.filter(email=email).exists():
user = User.objects.create_user(email=email, password='password123')
users.append(user)
if not users:
# If all generated users already existed, create one with a predictable email
user = User.objects.create_user(email='testuser@example.com', password='password123')
users.append(user)
self.stdout.write(f"Created {len(users)} sample users.")
# Create sample customers
customers = []
for _ in range(150):
customer = Customer.objects.create(
name=faker.company(),
email=faker.unique.email(),
phone=faker.phone_number(),
status=random.choice(['Active', 'Inactive', 'Prospective']),
tenant=random.choice(tenants),
created_by=random.choice(users),
updated_by=random.choice(users),
)
customers.append(customer)
self.stdout.write(f"Created {len(customers)} sample customers.")
# Create related objects
for customer in customers:
# Create contact history
for _ in range(random.randint(0, 5)):
ContactHistory.objects.create(
customer=customer,
user=random.choice(users),
note=faker.sentence(),
interaction_type=random.choice(['Call', 'Email', 'Meeting']),
)
# Create leads
leads = []
for _ in range(random.randint(0, 3)):
lead = Lead.objects.create(
customer=customer,
source=random.choice(['Web', 'Referral', 'Partner']),
status=random.choice(['New', 'Contacted', 'Qualified']),
assigned_to=random.choice(users),
)
leads.append(lead)
# Create opportunities
if leads:
for _ in range(random.randint(0, 2)):
Opportunity.objects.create(
lead=random.choice(leads),
value=faker.pydecimal(left_digits=5, right_digits=2, positive=True),
stage=random.choice(['Prospecting', 'Proposal', 'Negotiation', 'Closed Won', 'Closed Lost']),
probability=random.uniform(0.1, 0.9),
close_date=faker.future_date(),
)
self.stdout.write("Seeding complete.")

25
core/middleware.py Normal file
View File

@ -0,0 +1,25 @@
from datetime import datetime
from django.conf import settings
from django.utils import timezone
class UndoSessionCleanup:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
undo_data = request.session.get('undo_data')
if undo_data and 'timestamp' in undo_data:
try:
undo_timestamp = datetime.fromisoformat(undo_data['timestamp'])
# Ensure timestamp is timezone-aware for comparison
if timezone.is_naive(undo_timestamp):
undo_timestamp = timezone.make_aware(undo_timestamp, timezone.get_default_timezone())
if (timezone.now() - undo_timestamp).total_seconds() > settings.UNDO_TIMEOUT:
del request.session['undo_data']
except (ValueError, TypeError):
# If timestamp is invalid, remove it
del request.session['undo_data']
response = self.get_response(request)
return response

View File

@ -0,0 +1,157 @@
# Generated by Django 5.2.7 on 2025-11-22 14:20
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Tenant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('role', models.CharField(blank=True, max_length=100)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.tenant')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Customer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('name', models.CharField(max_length=255)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(blank=True, max_length=50)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('lead', 'Lead')], db_index=True, default='active', max_length=20)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.tenant')),
],
),
migrations.CreateModel(
name='ContactHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('note', models.TextField()),
('interaction_type', models.CharField(choices=[('email', 'Email'), ('phone', 'Phone Call'), ('meeting', 'Meeting'), ('note', 'Note')], default='note', max_length=20)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_history', to='core.customer')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Lead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('source', models.CharField(blank=True, max_length=255)),
('status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('unqualified', 'Unqualified')], db_index=True, default='new', max_length=20)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leads', to='core.customer')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Note',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('object_id', models.PositiveIntegerField()),
('content', models.TextField()),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Opportunity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('stage', models.CharField(choices=[('prospecting', 'Prospecting'), ('qualification', 'Qualification'), ('proposal', 'Proposal'), ('negotiation', 'Negotiation'), ('closed_won', 'Closed Won'), ('closed_lost', 'Closed Lost')], db_index=True, default='prospecting', max_length=20)),
('probability', models.FloatField(blank=True, null=True)),
('close_date', models.DateField(blank=True, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL)),
('lead', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opportunities', to='core.lead')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddIndex(
model_name='customer',
index=models.Index(fields=['tenant', 'status'], name='core_custom_tenant__21c6bd_idx'),
),
migrations.AddIndex(
model_name='customer',
index=models.Index(fields=['created_at'], name='core_custom_created_629a7b_idx'),
),
migrations.AlterUniqueTogether(
name='customer',
unique_together={('tenant', 'email')},
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2025-11-22 14:32
import core.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AlterModelManagers(
name='user',
managers=[
('objects', core.models.UserManager()),
],
),
migrations.RemoveField(
model_name='user',
name='username',
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, unique=True),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2025-11-22 15:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_alter_user_managers_remove_user_username_and_more'),
]
operations = [
migrations.AddField(
model_name='customer',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customers', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,115 @@
# Generated by Django 5.2.7 on 2025-11-22 22:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_customer_owner'),
]
operations = [
migrations.AddField(
model_name='contacthistory',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='contacthistory',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_deleted_by', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='customer',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='customer',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_deleted_by', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='lead',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='lead',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_deleted_by', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='note',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='note',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_deleted_by', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='opportunity',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='opportunity',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_deleted_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='contacthistory',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='contacthistory',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='customer',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='customer',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='lead',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='lead',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='note',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='note',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='opportunity',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='opportunity',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-22 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_contacthistory_deleted_at_contacthistory_deleted_by_and_more'),
]
operations = [
migrations.AddField(
model_name='opportunity',
name='name',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,65 @@
# Generated by Django 5.2.7 on 2025-11-22 22:27
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_opportunity_name'),
]
operations = [
migrations.AddField(
model_name='contacthistory',
name='restored_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='contacthistory',
name='restored_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_restored_by', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='customer',
name='restored_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='customer',
name='restored_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_restored_by', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='lead',
name='restored_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='lead',
name='restored_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_restored_by', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='note',
name='restored_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='note',
name='restored_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_restored_by', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='opportunity',
name='restored_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='opportunity',
name='restored_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_restored_by', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2025-11-22 22:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0006_contacthistory_restored_at_and_more'),
]
operations = [
migrations.CreateModel(
name='ActivityLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=255)),
('object_id', models.PositiveIntegerField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
],
options={
'ordering': ['-timestamp'],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-22 23:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_activitylog'),
]
operations = [
migrations.AddField(
model_name='activitylog',
name='details',
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-23 00:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_activitylog_details'),
]
operations = [
migrations.AlterField(
model_name='activitylog',
name='timestamp',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
]

View File

@ -1,3 +1,205 @@
from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
# Create your models here.
class UserManager(BaseUserManager):
"""Define a model manager for User model with no username field."""
use_in_migrations = True
def _create_user(self, email, password, **extra_fields):
"""Create and save a User with the given email and password."""
if not email:
raise ValueError('The given email must be set')
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, email, password=None, **extra_fields):
"""Create and save a regular User with the given email and password."""
extra_fields.setdefault('is_staff', False)
extra_fields.setdefault('is_superuser', False)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email, password, **extra_fields):
"""Create and save a SuperUser with the given email and password."""
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
if extra_fields.get('is_staff') is not True:
raise ValueError('Superuser must have is_staff=True.')
if extra_fields.get('is_superuser') is not True:
raise ValueError('Superuser must have is_superuser=True.')
return self._create_user(email, password, **extra_fields)
class Tenant(models.Model):
name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class User(AbstractUser):
username = None
email = models.EmailField(unique=True)
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, null=True, blank=True)
role = models.CharField(max_length=100, blank=True)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
objects = UserManager()
class BaseModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='%(class)s_created_by')
updated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='%(class)s_updated_by')
deleted_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='%(class)s_deleted_by')
restored_at = models.DateTimeField(null=True, blank=True)
restored_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='%(class)s_restored_by')
class Meta:
abstract = True
def delete(self, user=None, *args, **kwargs):
self.is_deleted = True
self.deleted_at = timezone.now()
if user:
self.deleted_by = user
self.save()
ActivityLog.objects.create(
actor=user,
action=f'deleted a {self.__class__.__name__}',
content_object=self,
details={'id': self.id, 'name': str(self)}
)
def restore(self, user=None, *args, **kwargs):
self.is_deleted = False
self.deleted_at = None
self.deleted_by = None
self.restored_at = timezone.now()
if user:
self.restored_by = user
self.save()
ActivityLog.objects.create(
actor=user,
action=f'restored a {self.__class__.__name__}',
content_object=self,
details={'id': self.id, 'name': str(self)}
)
class ActivityLog(models.Model):
actor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
action = models.CharField(max_length=255)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
details = models.JSONField(null=True, blank=True)
class Meta:
ordering = ['-timestamp']
def get_action_badge(self):
if 'deleted' in self.action:
return ('bg-danger', 'deleted')
elif 'restored' in self.action:
return ('bg-success', 'restored')
elif 'created' in self.action:
return ('bg-primary', 'created')
elif 'updated' in self.action:
return ('bg-info', 'updated')
else:
action_verb = self.action.split(' ')[0]
return ('bg-secondary', action_verb)
class Customer(BaseModel):
STATUS_CHOICES = (
('active', 'Active'),
('inactive', 'Inactive'),
('lead', 'Lead'),
)
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
email = models.EmailField()
phone = models.CharField(max_length=50, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active', db_index=True)
owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='customers')
class Meta:
unique_together = ('tenant', 'email')
indexes = [
models.Index(fields=['tenant', 'status']),
models.Index(fields=['created_at']),
]
def __str__(self):
return self.name
class Lead(BaseModel):
STATUS_CHOICES = (
('new', 'New'),
('contacted', 'Contacted'),
('qualified', 'Qualified'),
('unqualified', 'Unqualified'),
)
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='leads')
source = models.CharField(max_length=255, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new', db_index=True)
assigned_to = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='leads')
def __str__(self):
return f"Lead for {self.customer.name}"
class Opportunity(BaseModel):
STAGE_CHOICES = (
('prospecting', 'Prospecting'),
('qualification', 'Qualification'),
('proposal', 'Proposal'),
('negotiation', 'Negotiation'),
('closed_won', 'Closed Won'),
('closed_lost', 'Closed Lost'),
)
name = models.CharField(max_length=255, blank=True, null=True)
lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name='opportunities')
value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
stage = models.CharField(max_length=20, choices=STAGE_CHOICES, default='prospecting', db_index=True)
probability = models.FloatField(null=True, blank=True)
close_date = models.DateField(null=True, blank=True)
def __str__(self):
return self.name
class ContactHistory(BaseModel):
INTERACTION_CHOICES = (
('email', 'Email'),
('phone', 'Phone Call'),
('meeting', 'Meeting'),
('note', 'Note'),
)
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='contact_history')
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
note = models.TextField()
interaction_type = models.CharField(max_length=20, choices=INTERACTION_CHOICES, default='note')
def __str__(self):
return f"Contact with {self.customer.name} on {self.created_at.date()}"
class Note(BaseModel):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
content = models.TextField()
def __str__(self):
return f"Note for {self.content_object}"

18
core/opportunity_urls.py Normal file
View File

@ -0,0 +1,18 @@
from django.urls import path
from .views import (
OpportunityListView,
OpportunityDetailView,
OpportunityCreateView,
OpportunityUpdateView,
OpportunityDeleteView,
)
app_name = 'opportunities'
urlpatterns = [
path('', OpportunityListView.as_view(), name='opportunity_list'),
path('<int:pk>/', OpportunityDetailView.as_view(), name='opportunity_detail'),
path('create/', OpportunityCreateView.as_view(), name='opportunity_create'),
path('<int:pk>/update/', OpportunityUpdateView.as_view(), name='opportunity_update'),
path('<int:pk>/delete/', OpportunityDeleteView.as_view(), name='opportunity_delete'),
]

View File

@ -1,11 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}CRM Platform{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&family=Inter:wght@400;500&display=swap" rel="stylesheet">
{% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}">
<script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
{% block head %}{% endblock %}
</head>
<body>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">CRM</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{% url 'customer-list' %}">Customers</a>
</li>
</ul>
</div>
</div>
</nav>
{% block content %}{% endblock %}
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
</body>
</html>

View File

@ -0,0 +1,20 @@
<div class="card">
<div class="card-header">
Activity Feed
</div>
<ul class="list-group list-group-flush">
{% for log in activity_logs %}
{% with log.get_action_badge as badge %}
<li class="list-group-item">
<strong>{{ log.actor.email }}</strong>
<span class="badge {{ badge.0 }}" data-bs-toggle="tooltip" data-bs-html="true" title="{% if log.details %}{% if 'updated' in log.action %}{% for key, value in log.details.items %}<b>{{ key }}</b>: {{ value.old }} -> {{ value.new }}<br>{% endfor %}{% else %}{{ log.details.name }}{% endif %}{% endif %}">{{ badge.1 }}</span>
a {{ log.content_object.__class__.__name__ }}
-
<small title="{{ log.timestamp }}">{{ log.timestamp|timesince }} ago</small>
</li>
{% endwith %}
{% empty %}
<li class="list-group-item">No recent activity.</li>
{% endfor %}
</ul>
</div>

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Delete Customer</h2>
<p>Are you sure you want to delete "{{ object.name }}"?</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Confirm Delete</button>
<a href="{% url 'customer_detail' pk=object.pk %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,206 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container mt-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'customer-list' %}">Customers</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ customer.name }}</li>
</ol>
</nav>
{% if customer.is_deleted %}
<div class="alert alert-danger" role="alert">
This customer was deleted on {{ customer.deleted_at|date:"Y-m-d H:i" }} by {{ customer.deleted_by.get_full_name|default:"Unknown" }}.
</div>
{% endif %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="mb-0">{{ customer.name }}</h2>
<div>
<a href="{% url 'customer_edit' customer.pk %}" class="btn btn-primary me-2 {% if customer.is_deleted %}disabled{% endif %}" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit this customer's details">
Edit
</a>
<a href="{% url 'customer_delete' customer.pk %}" class="btn btn-danger {% if customer.is_deleted %}disabled{% endif %}" data-bs-toggle="tooltip" data-bs-placement="top" title="Delete this customer" onclick="return confirm('Are you sure you want to delete this customer?');">
Delete
</a>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h4>Details</h4>
<table class="table table-sm">
<tr>
<th>Email:</th>
<td>{{ customer.email }}</td>
</tr>
<tr>
<th>Status:</th>
<td>{{ customer.get_status_display }}</td>
</tr>
<tr>
<th>Owner:</th>
<td>{{ customer.owner.get_full_name|default:"N/A" }}</td>
</tr>
<tr>
<th>Created At:</th>
<td>{{ customer.created_at|date:"Y-m-d H:i" }}</td>
</tr>
<tr>
<th>Tenant:</th>
<td>{{ customer.tenant.name }}</td>
</tr>
{% if customer.restored_at %}
<tr>
<th>Restored At:</th>
<td>{{ customer.restored_at|date:"Y-m-d H:i" }}</td>
</tr>
<tr>
<th>Restored By:</th>
<td>{{ customer.restored_by.get_full_name|default:"Unknown" }}</td>
</tr>
{% endif %}
</table>
</div>
<div class="col-md-6">
<h4>Summary</h4>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center">
Opportunities
<span class="badge bg-primary rounded-pill">{{ opportunity_count }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Leads
<span class="badge bg-primary rounded-pill">{{ lead_count }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Notes
<span class="badge bg-primary rounded-pill">{{ note_count }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="customer-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="opportunities-tab" data-bs-toggle="tab" data-bs-target="#opportunities" type="button" role="tab" aria-controls="opportunities" aria-selected="true">Opportunities ({{ opportunity_count }})</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="leads-tab" data-bs-toggle="tab" data-bs-target="#leads" type="button" role="tab" aria-controls="leads" aria-selected="false">Leads ({{ lead_count }})</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab" aria-controls="history" aria-selected="false">Contact History</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab" aria-controls="notes" aria-selected="false">Notes ({{ note_count }})</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="customer-tabs-content">
<div class="tab-pane fade show active" id="opportunities" role="tabpanel" aria-labelledby="opportunities-tab">
<h4>Opportunities</h4>
{% if opportunities %}
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Stage</th>
<th>Value</th>
<th>Close Date</th>
</tr>
</thead>
<tbody>
{% for op in opportunities %}
<tr>
<td>{{ op.name }}</td>
<td>{{ op.get_stage_display }}</td>
<td>${{ op.value }}</td>
<td>{{ op.close_date|date:"Y-m-d" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No opportunities found for this customer.</p>
{% endif %}
</div>
<div class="tab-pane fade" id="leads" role="tabpanel" aria-labelledby="leads-tab">
<h4>Leads</h4>
{% if leads %}
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{% for lead in leads %}
<tr>
<td>{{ lead.name }}</td>
<td>{{ lead.get_status_display }}</td>
<td>{{ lead.source }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No leads found for this customer.</p>
{% endif %}
</div>
<div class="tab-pane fade" id="history" role="tabpanel" aria-labelledby="history-tab">
<h4>Contact History</h4>
{% if contact_history %}
<ul class="list-group">
{% for entry in contact_history %}
<li class="list-group-item">
<strong>{{ entry.created_at|date:"Y-m-d H:i" }}:</strong> {{ entry.notes }} ({{ entry.get_interaction_type_display }})
</li>
{% endfor %}
</ul>
{% else %}
<p>No contact history found.</p>
{% endif %}
</div>
<div class="tab-pane fade" id="notes" role="tabpanel" aria-labelledby="notes-tab">
<h4>Notes</h4>
{% if notes %}
<ul class="list-group">
{% for note in notes %}
<li class="list-group-item">
<p>{{ note.note }}</p>
<small class="text-muted">By {{ note.user.get_full_name }} on {{ note.created_at|date:"Y-m-d H:i" }}</small>
</li>
{% endfor %}
</ul>
{% else %}
<p>No notes found.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
});
</script>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Edit Customer: {{ customer.name }}</h2>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
<div class="card">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="id_first_name" class="form-label">First Name</label>
{{ form.first_name }}
</div>
<div class="mb-3">
<label for="id_last_name" class="form-label">Last Name</label>
{{ form.last_name }}
</div>
<div class="mb-3">
<label for="id_email" class="form-label">Email</label>
{{ form.email }}
</div>
<div class="mb-3">
<label for="id_phone" class="form-label">Phone</label>
{{ form.phone }}
</div>
<div class="mb-3">
<label for="id_status" class="form-label">Status</label>
{{ form.status }}
</div>
<div class="mb-3">
<label for="id_owner" class="form-label">Owner</label>
{{ form.owner }}
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{% url 'customer_detail' customer.pk %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,465 @@
{% extends 'base.html' %}
{% load query_string %}
{% block content %}
<div class="container mt-5">
<h1>Customers</h1>
<!-- Search and Filter Form -->
<form method="get" class="mb-4">
<div class="row">
<div class="col-md-4">
<input type="text" name="q" class="form-control" placeholder="Search by name, email, or ID..." value="{{ query }}" aria-label="Search Customers">
</div>
<div class="col-md-2">
<select name="status" class="form-control" aria-label="Filter by Status">
<option value="">All Statuses</option>
{% for value, display in statuses %}
<option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ display }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select name="owner" class="form-control" aria-label="Filter by Owner">
<option value="">All Owners</option>
{% for owner in owners %}
<option value="{{ owner.id }}" {% if owner.id|stringformat:"s" == selected_owner %}selected{% endif %}>{{ owner.email }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="show_deleted" id="show_deleted" {% if show_deleted %}checked{% endif %} aria-label="Show Deleted Customers">
<label class="form-check-label" for="show_deleted">
Show Deleted
</label>
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary" aria-label="Apply Filters">Filter</button>
</div>
</div>
</form>
<div class="row mb-4">
<div class="col-md-12">
<form method="get" class="mb-2">
<div class="row">
<div class="col-md-3">
<select name="activity_user" class="form-control" aria-label="Filter by User">
<option value="">All Users</option>
{% for user in activity_users %}
<option value="{{ user.id }}" {% if user.id|stringformat:"s" == selected_activity_user %}selected{% endif %}>{{ user.email }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<select name="activity_action" class="form-control" aria-label="Filter by Action">
<option value="">All Actions</option>
{% for action in activity_actions %}
<option value="{{ action }}" {% if action == selected_activity_action %}selected{% endif %}>{{ action }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<input type="text" name="activity_date_range" class="form-control" placeholder="Select date range..." value="{{ selected_activity_date_range }}" aria-label="Filter by Date Range">
</div>
<div class="col-md-auto">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setDateRange('today')" aria-label="Set Date Range to Today">Today</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setDateRange('7days')" aria-label="Set Date Range to Last 7 Days">Last 7 Days</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setDateRange('30days')" aria-label="Set Date Range to Last 30 Days">Last 30 Days</button>
</div>
<div class="col-md-2">
<select name="activity_sort" class="form-control" aria-label="Sort Activity Feed">
<option value="-timestamp" {% if selected_activity_sort == "-timestamp" %}selected{% endif %}>Newest First</option>
<option value="timestamp" {% if selected_activity_sort == "timestamp" %}selected{% endif %}>Oldest First</option>
<option value="actor" {% if selected_activity_sort == "actor" %}selected{% endif %}>User (A-Z)</option>
<option value="-actor" {% if selected_activity_sort == "-actor" %}selected{% endif %}>User (Z-A)</option>
<option value="action" {% if selected_activity_sort == "action" %}selected{% endif %}>Action (A-Z)</option>
<option value="-action" {% if selected_activity_sort == "-action" %}selected{% endif %}>Action (Z-A)</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-secondary" aria-label="Apply Activity Feed Filters">Filter Activity</button>
</div>
<div class="col-md-2">
<a href="?clear_filters=true" class="btn btn-outline-secondary" aria-label="Clear All Filters">Clear Filters</a>
</div>
</div>
</form>
<div class="text-muted mb-2">
Showing {{ activity_logs.start_index }} - {{ activity_logs.end_index }} of {{ activity_log_count }} activities — Page {{ activity_logs.number }} of {{ activity_paginator.num_pages }}
</div>
<div>
<form method="get" class="form-inline">
<input type="hidden" name="q" value="{{ query }}">
<input type="hidden" name="status" value="{{ selected_status }}">
<input type="hidden" name="owner" value="{{ selected_owner }}">
<input type="hidden" name="show_deleted" value="{{ show_deleted }}">
<input type="hidden" name="activity_user" value="{{ selected_activity_user }}">
<input type="hidden" name="activity_action" value="{{ selected_activity_action }}">
<input type="hidden" name="activity_date_range" value="{{ selected_activity_date_range }}">
<input type="hidden" name="activity_sort" value="{{ selected_activity_sort }}">
<label for="activity_page_size" class="mr-2">Show:</label>
<select name="activity_page_size" id="activity_page_size" class="form-control form-control-sm" onchange="this.form.submit()">
<option value="5" {% if activity_page_size == 5 %}selected{% endif %}>5</option>
<option value="10" {% if activity_page_size == 10 %}selected{% endif %}>10</option>
<option value="25" {% if activity_page_size == 25 %}selected{% endif %}>25</option>
<option value="50" {% if activity_page_size == 50 %}selected{% endif %}>50</option>
</select>
</form>
</div>
<div class="my-3">
<strong>Recent Activity:</strong>
<span class="badge bg-info">{{ recent_activity_count }} actions in the last 24 hours.</span>
</div>
<div class="my-3">
<strong>Activity Summary:</strong>
{% for action, count in activity_summary.items %}
<span class="badge bg-secondary">{{ action }}: {{ count }}</span>
{% empty %}
<span class="text-muted">No activity in current view.</span>
{% endfor %}
</div>
{% include 'core/_activity_feed.html' %}
<!-- Activity Feed Pagination -->
<nav aria-label="Activity Feed Pagination">
<ul class="pagination justify-content-center">
{% if activity_logs.has_previous %}
<li class="page-item"><a class="page-link" href="?{% query_string request activity_page=1 %}" aria-label="First Page of Activity Feed">
<span aria-hidden="true">&laquo;&laquo;</span>
</a></li>
<li class="page-item"><a class="page-link" href="?{% query_string request activity_page=activity_logs.previous_page_number %}" aria-label="Previous Page of Activity Feed">
<span aria-hidden="true">&laquo;</span>
</a></li>
{% else %}
<li class="page-item disabled"><a class="page-link" href="#" aria-label="First Page of Activity Feed">
<span aria-hidden="true">&laquo;&laquo;</span>
</a></li>
<li class="page-item disabled"><a class="page-link" href="#" aria-label="Previous Page of Activity Feed">
<span aria-hidden="true">&laquo;</span>
</a></li>
{% endif %}
<li class="page-item active"><a class="page-link bg-primary text-white" href="#">{{ activity_logs.number }}</a></li>
{% if activity_logs.has_next %}
<li class="page-item"><a class="page-link" href="?{% query_string request activity_page=activity_logs.next_page_number %}" aria-label="Next Page of Activity Feed">
<span aria-hidden="true">&raquo;</span>
</a></li>
<li class="page-item"><a class="page-link" href="?{% query_string request activity_page=activity_paginator.num_pages %}" aria-label="Last Page of Activity Feed">
<span aria-hidden="true">&raquo;&raquo;</span>
</a></li>
{% else %}
<li class="page-item disabled"><a class="page-link" href="#" aria-label="Next Page of Activity Feed">
<span aria-hidden="true">&raquo;</span>
</a></li>
<li class="page-item disabled"><a class="page-link" href="#" aria-label="Last Page of Activity Feed">
<span aria-hidden="true">&raquo;&raquo;</span>
</a></li>
{% endif %}
</ul>
</nav>
<div class="text-center">
<span class="text-muted">Page {{ activity_logs.number }} of {{ activity_paginator.num_pages }}</span>
<div class="d-inline-block ms-3">
<form method="get" class="form-inline">
<input type="hidden" name="q" value="{{ query }}">
<input type="hidden" name="status" value="{{ selected_status }}">
<input type="hidden" name="owner" value="{{ selected_owner }}">
<input type="hidden" name="show_deleted" value="{{ show_deleted }}">
<input type="hidden" name="activity_user" value="{{ selected_activity_user }}">
<input type="hidden" name="activity_action" value="{{ selected_activity_action }}">
<input type="hidden" name="activity_date_range" value="{{ selected_activity_date_range }}">
<input type="number" name="activity_page" min="1" max="{{ activity_paginator.num_pages }}" placeholder="Page" class="form-control form-control-sm d-inline-block" style="width: 80px;">
<button type="submit" class="btn btn-sm btn-primary">Go</button>
</form>
</div>
</div>
</div>
</div>
<!-- Page size selector -->
<div class="d-flex justify-content-between mb-3">
<div>
<span class="text-muted">Showing {{ page_obj.start_index }} - {{ page_obj.end_index }} of {{ customer_count }} customers — Page {{ page_obj.number }} of {{ paginator.num_pages }}</span>
</div>
<div>
<form method="get" class="form-inline">
<input type="hidden" name="q" value="{{ query }}">
<input type="hidden" name="status" value="{{ selected_status }}">
<input type="hidden" name="owner" value="{{ selected_owner }}">
<input type="hidden" name="show_deleted" value="{{ show_deleted }}">
<label for="page_size" class="mr-2">Show:</label>
<select name="page_size" id="page_size" class="form-control form-control-sm" onchange="this.form.submit()">
<option value="10" {% if page_size == 10 %}selected{% endif %}>10</option>
<option value="25" {% if page_size == 25 %}selected{% endif %}>25</option>
<option value="50" {% if page_size == 50 %}selected{% endif %}>50</option>
<option value="100" {% if page_size == 100 %}selected{% endif %}>100</option>
</select>
</form>
</div>
</div>
<form method="post" action="{% url 'customer_bulk_action' %}">
{% csrf_token %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Status</th>
<th>Owner</th>
<th>Last Contact</th>
<th>Opportunities</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for customer in page_obj %}
<tr data-customer-id="{{ customer.pk }}" class="{% if customer.pk in highlighted_customer_ids %}highlight-animation{% endif %}{% if is_filter_active and not customer.pk in highlighted_customer_ids %}highlight{% endif %}">
<td><input type="checkbox" name="selected_customers" value="{{ customer.pk }}"></td>
<td>{{ customer.name }}</td>
<td>{{ customer.email }}</td>
<td>{{ customer.phone }}</td>
<td>{{ customer.get_status_display }}</td>
<td>{{ customer.owner.email|default:"-" }}</td>
<td>{{ customer.last_contact|date:"Y-m-d"|default:"-" }}</td>
<td>{{ customer.opportunity_count }}</td>
<td>
<a href="{% url 'customer_detail' customer.pk %}" class="btn btn-sm btn-info mr-1">View</a>
<a href="{% url 'customer_edit' customer.pk %}" class="btn btn-sm btn-warning mr-1">Edit</a>
<a href="{% url 'customer_delete' customer.pk %}" class="btn btn-sm btn-danger">Delete</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9">No customers found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="submit" name="action" value="restore" class="btn btn-success">Restore Selected</button>
<button type="submit" name="action" value="delete" class="btn btn-danger">Delete Selected</button>
</form>
<!-- Pagination -->
<nav aria-label="Customer Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?{% query_string request page=1 %}" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a></li>
<li class="page-item"><a class="page-link" href="?{% query_string request page=page_obj.previous_page_number %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a></li>
{% else %}
<li class="page-item disabled"><a class="page-link" href="#" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a></li>
<li class="page-item disabled"><a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a></li>
{% endif %}
<li class="page-item active"><a class="page-link bg-primary text-white" href="#">{{ page_obj.number }}</a></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?{% query_string request page=page_obj.next_page_number %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a></li>
<li class="page-item"><a class="page-link" href="?{% query_string request page=paginator.num_pages %}" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a></li>
{% else %}
<li class="page-item disabled"><a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a></li>
<li class="page-item disabled"><a class="page-link" href="#" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a></li>
{% endif %}
</ul>
</nav>
<div class="text-center">
<span class="text-muted">Page {{ page_obj.number }} of {{ paginator.num_pages }}</span>
<div class="d-inline-block ms-3">
<form method="get" class="form-inline">
<input type="hidden" name="q" value="{{ query }}">
<input type="hidden" name="status" value="{{ selected_status }}">
<input type="hidden" name="owner" value="{{ selected_owner }}">
<input type="hidden" name="show_deleted" value="{{ show_deleted }}">
<input type="number" name="page" min="1" max="{{ paginator.num_pages }}" placeholder="Page" class="form-control form-control-sm d-inline-block" style="width: 80px;">
<button type="submit" class="btn btn-sm btn-primary">Go</button>
</form>
</div>
</div>
</div>
<style>
@keyframes fadeOutHighlight {
from {
background-color: #d4edda; /* Light green */
}
to {
background-color: transparent;
}
}
.highlight-animation {
animation: fadeOutHighlight 3s ease-in-out;
}
.highlight {
background-color: #f8f9fa; /* A light grey for filtered results */
}
</style>
<script>
document.getElementById('select-all').onclick = function() {
var checkboxes = document.getElementsByName('selected_customers');
for (var checkbox of checkboxes) {
checkbox.checked = this.checked;
}
}
<div id="undo-container" class="alert alert-warning d-none">
<span id="undo-message"></span>
<a href="#" id="undo-link">Undo</a>
<span id="undo-countdown"></span>
</div>
<script>
function startUndoCountdown(timeout, undoUrl) {
const undoContainer = document.getElementById('undo-container');
const undoMessage = document.getElementById('undo-message');
const undoLink = document.getElementById('undo-link');
const undoCountdown = document.getElementById('undo-countdown');
undoMessage.textContent = 'Action successful.';
undoLink.href = undoUrl;
undoContainer.classList.remove('d-none');
let timeLeft = timeout;
undoCountdown.textContent = ` (Undo available for ${timeLeft} seconds)`;
const timer = setInterval(() => {
timeLeft--;
undoCountdown.textContent = ` (Undo available for ${timeLeft} seconds)`;
if (timeLeft <= 0) {
clearInterval(timer);
undoContainer.classList.add('d-none');
}
}, 1000);
setTimeout(() => {
clearInterval(timer);
undoContainer.classList.add('d-none');
}, timeout * 1000);
}
{% if messages %}
{% for message in messages %}
try {
const data = JSON.parse('{{ message|escapejs }}');
if (data.undo_url) {
startUndoCountdown({{ undo_timeout }}, data.undo_url);
} else {
Swal.fire({
toast: true,
position: '{{ toast_position|default:"top-end" }}',
icon: data.icon || 'success',
title: data.message,
showConfirmButton: false,
timer: 3000,
timerProgressBar: true
});
}
} catch (e) {
// Not a JSON message, treat as plain text
Swal.fire({
toast: true,
position: '{{ toast_position|default:"top-end" }}',
icon: 'success',
title: '{{ message|safe }}',
showConfirmButton: false,
timer: 3000,
timerProgressBar: true
});
}
{% endfor %}
{% endif %}
</script>
<script>
flatpickr("input[name='activity_date_range']", {
mode: "range",
dateFormat: "Y-m-d",
defaultDate: "{{ selected_activity_date_range }}"
});
function setDateRange(range) {
const dateRangeInput = document.querySelector("input[name='activity_date_range']");
const today = new Date();
let startDate = new Date();
const endDate = new Date().toISOString().slice(0, 10);
if (range === 'today') {
startDate.setDate(today.getDate());
} else if (range === '7days') {
startDate.setDate(today.getDate() - 7);
} else if (range === '30days') {
startDate.setDate(today.getDate() - 30);
}
const startDateStr = startDate.toISOString().slice(0, 10);
const fp = flatpickr(dateRangeInput, {
mode: "range",
dateFormat: "Y-m-d",
});
fp.setDate([startDateStr, endDate]);
}
document.addEventListener('keydown', function(event) {
if (event.key === 'ArrowLeft') {
const prevLink = document.querySelector('a[aria-label="Previous Page of Activity Feed"]');
if (prevLink) {
prevLink.click();
}
} else if (event.key === 'ArrowRight') {
const nextLink = document.querySelector('a[aria-label="Next Page of Activity Feed"]');
if (nextLink) {
nextLink.click();
}
}
});
document.addEventListener('keydown', function(event) {
if (event.key === 'ArrowLeft') {
const prevLink = document.querySelector('a[aria-label="Previous"]');
if (prevLink) {
prevLink.click();
}
} else if (event.key === 'ArrowRight') {
const nextLink = document.querySelector('a[aria-label="Next"]');
if (nextLink) {
nextLink.click();
}
}
});
</script>
<script>
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
</script>
{% endblock %}

View File

@ -1,154 +1,56 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ project_name }}{% endblock %}
{% block title %}Autonomous CRM & AI Agents{% endblock %}
{% block head %}
{% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}">
{% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
<meta name="description" content="The future of customer relationship management with autonomous AI agents for sales, support, and appointment setting.">
{% endblock %}
{% block content %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div>
<header class="hero text-center text-white">
<div class="container hero-content">
<h1 class="display-4">The Autonomous CRM Platform</h1>
<p class="lead">Supercharge your business with AI agents for sales, support, and scheduling that work for you 24/7.</p>
<a href="#" class="btn btn-primary btn-lg">Get Started Now</a>
</div>
</header>
<section class="py-5">
<div class="container text-center">
<h2 class="mb-4">Core Features</h2>
<div class="row">
<div class="col-md-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">AI Sales Agent</h5>
<p class="card-text">Automatically nurtures leads, scores prospects, and closes deals.</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">AI Support Agent</h5>
<p class="card-text">Resolves customer tickets instantly with a fully contextual knowledge base.</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">AI Appointment Setter</h5>
<p class="card-text">Schedules and confirms meetings seamlessly across all your calendars.</p>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
<footer class="text-center py-4" style="background-color: var(--bs-body-bg);">
<p class="mb-0">&copy; {% now "Y" %} Your Company. All Rights Reserved.</p>
</footer>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mt-5">
<h1>Delete Opportunity</h1>
<p>Are you sure you want to delete the opportunity "{{ object.name }}"?</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Delete</button>
<a href="{% url 'opportunities:opportunity_detail' object.pk %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,49 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mt-5">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>{{ opportunity.name }}</h1>
<div>
<a href="{% url 'opportunities:opportunity_update' opportunity.pk %}" class="btn btn-warning">Edit</a>
<a href="{% url 'opportunities:opportunity_delete' opportunity.pk %}" class="btn btn-danger">Delete</a>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Opportunity Details</h5>
<table class="table">
<tr>
<th>Lead</th>
<td>{{ opportunity.lead.customer.name }}</td>
</tr>
<tr>
<th>Stage</th>
<td>{{ opportunity.get_stage_display }}</td>
</tr>
<tr>
<th>Value</th>
<td>${{ opportunity.value|default:0 }}</td>
</tr>
<tr>
<th>Probability</th>
<td>{{ opportunity.probability|default:0 }}%</td>
</tr>
<tr>
<th>Close Date</th>
<td>{{ opportunity.close_date|date:"Y-m-d" }}</td>
</tr>
<tr>
<th>Created By</th>
<td>{{ opportunity.created_by.email }}</td>
</tr>
<tr>
<th>Created At</th>
<td>{{ opportunity.created_at|date:"Y-m-d H:i" }}</td>
</tr>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mt-5">
<h1>{% if object %}Edit Opportunity{% else %}Create Opportunity{% endif %}</h1>
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="id_name" class="form-label">Name</label>
{{ form.name }}
</div>
<div class="mb-3">
<label for="id_lead" class="form-label">Lead</label>
{{ form.lead }}
</div>
<div class="mb-3">
<label for="id_value" class="form-label">Value</label>
{{ form.value }}
</div>
<div class="mb-3">
<label for="id_stage" class="form-label">Stage</label>
{{ form.stage }}
</div>
<div class="mb-3">
<label for="id_probability" class="form-label">Probability</label>
{{ form.probability }}
</div>
<div class="mb-3">
<label for="id_close_date" class="form-label">Close Date</label>
{{ form.close_date }}
</div>
<button type="submit" class="btn btn-primary">{% if object %}Update{% else %}Create{% endif %}</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,39 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mt-5">
<h1>Opportunities</h1>
<a href="{% url 'opportunities:opportunity_create' %}" class="btn btn-primary mb-3">Create Opportunity</a>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Lead</th>
<th>Stage</th>
<th>Value</th>
<th>Close Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for opportunity in opportunities %}
<tr>
<td><a href="{% url 'opportunities:opportunity_detail' opportunity.pk %}">{{ opportunity.name }}</a></td>
<td>{{ opportunity.lead.customer.name }}</td>
<td>{{ opportunity.get_stage_display }}</td>
<td>${{ opportunity.value|default:0 }}</td>
<td>{{ opportunity.close_date|date:"Y-m-d" }}</td>
<td>
<a href="{% url 'opportunities:opportunity_update' opportunity.pk %}" class="btn btn-sm btn-warning">Edit</a>
<a href="{% url 'opportunities:opportunity_delete' opportunity.pk %}" class="btn btn-sm btn-danger">Delete</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6">No opportunities found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

Binary file not shown.

View File

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.simple_tag
def query_string(request, **kwargs):
query_dict = request.GET.copy()
for key, value in kwargs.items():
query_dict[key] = value
return query_dict.urlencode()

View File

@ -1,7 +1,16 @@
from django.urls import path
from django.urls import path, include
from .views import home
from .views import home, CustomerListView, CustomerDetailView, CustomerUpdateView, CustomerDeleteView, CustomerRestoreView, CustomerBulkActionView, CustomerBulkUndoView, CustomerUndoView
urlpatterns = [
path("", home, name="home"),
]
path("customers/", CustomerListView.as_view(), name="customer-list"),
path('customers/<uuid:pk>/', CustomerDetailView.as_view(), name='customer_detail'),
path('customer/<uuid:pk>/edit/', CustomerUpdateView.as_view(), name='customer_edit'),
path('customer/<uuid:pk>/delete/', CustomerDeleteView.as_view(), name='customer_delete'),
path('customer/<uuid:pk>/restore/', CustomerRestoreView.as_view(), name='customer_restore'),
path('customers/bulk-action/', CustomerBulkActionView.as_view(), name='customer_bulk_action'),
path('customers/bulk-undo/', CustomerBulkUndoView.as_view(), name='customer_bulk_undo'),
path('customer/undo/', CustomerUndoView.as_view(), name='customer_undo'),
path('opportunities/', include('core.opportunity_urls', namespace='opportunities')),
]

View File

@ -4,6 +4,8 @@ import platform
from django import get_version as django_version
from django.shortcuts import render
from django.utils import timezone
from django.views.generic import ListView
from .models import Customer
def home(request):
@ -23,3 +25,510 @@ def home(request):
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
}
return render(request, "core/index.html", context)
def article_detail(request):
"""Render the article detail screen."""
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
context = {
"project_name": "New Style",
"agent_brand": agent_brand,
"django_version": django_version(),
"python_version": platform.python_version(),
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image__url": os.getenv("PROJECT_IMAGE_URL", ""),
}
return render(request, "core/article_detail.html", context)
from django.conf import settings
from django.core.paginator import Paginator
from django.db.models import Q, Count, Subquery, OuterRef
from .models import User, ContactHistory, Opportunity, ActivityLog
from django.shortcuts import get_object_or_404
from django.views import View
class CustomerListView(ListView):
model = Customer
template_name = 'core/customer_list.html'
context_object_name = 'customers'
paginate_by = 25 # Default page size
def get_queryset(self):
queryset = super().get_queryset().select_related('owner')
# Filter persistence
if self.request.GET.get('clear_filters'):
for key in list(self.request.session.keys()):
if key.startswith('customer_list_filter_'):
del self.request.session[key]
filter_params = {
'show_deleted': self.request.GET.get('show_deleted'),
'q': self.request.GET.get('q'),
'status': self.request.GET.get('status'),
'owner': self.request.GET.get('owner'),
}
for key, value in filter_params.items():
if value is not None:
self.request.session[f'customer_list_filter_{key}'] = value
else:
filter_params[key] = self.request.session.get(f'customer_list_filter_{key}')
# Filter by deletion status
if not filter_params['show_deleted']:
queryset = queryset.filter(is_deleted=False)
# Search
if filter_params['q']:
queryset = queryset.filter(
Q(name__icontains=filter_params['q']) |
Q(email__icontains=filter_params['q']) |
Q(id__icontains=filter_params['q'])
)
# Filter
if filter_params['status']:
queryset = queryset.filter(status=filter_params['status'])
if filter_params['owner']:
queryset = queryset.filter(owner_id=filter_params['owner'])
# Annotate with last contact and opportunity count
last_contact_subquery = ContactHistory.objects.filter(
customer=OuterRef('pk')
).order_by('-created_at').values('created_at')[:1]
queryset = queryset.annotate(
last_contact=Subquery(last_contact_subquery),
opportunity_count=Count('opportunities', distinct=True)
).order_by('name')
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
page_size = self.request.GET.get('page_size', self.paginate_by)
paginator = Paginator(self.get_queryset(), page_size)
page_number = self.request.GET.get('page')
page_obj = paginator.get_page(page_number)
context['page_obj'] = page_obj
context['page_size'] = int(page_size)
context['paginator'] = paginator
context['customer_count'] = paginator.count
context['statuses'] = Customer.STATUS_CHOICES
context['owners'] = User.objects.filter(is_staff=True) # Assuming staff are owners
# Persist query params
context['query'] = self.request.session.get('customer_list_filter_q', '')
context['selected_status'] = self.request.session.get('customer_list_filter_status', '')
context['selected_owner'] = self.request.session.get('customer_list_filter_owner', '')
context['show_deleted'] = self.request.session.get('customer_list_filter_show_deleted', False)
is_filter_active = any([
self.request.session.get('customer_list_filter_q'),
self.request.session.get('customer_list_filter_status'),
self.request.session.get('customer_list_filter_owner'),
self.request.session.get('customer_list_filter_show_deleted'),
self.request.session.get('activity_feed_filter_activity_user'),
self.request.session.get('activity_feed_filter_activity_action'),
self.request.session.get('activity_feed_filter_activity_date_range'),
])
context['is_filter_active'] = is_filter_active
highlighted_customer_ids = []
updated_customer_id = self.request.session.pop('updated_customer_id', None)
if updated_customer_id:
highlighted_customer_ids.append(updated_customer_id)
deleted_customer_id = self.request.session.pop('deleted_customer_id', None)
if deleted_customer_id:
highlighted_customer_ids.append(deleted_customer_id)
restored_customer_id = self.request.session.pop('restored_customer_id', None)
if restored_customer_id:
highlighted_customer_ids.append(restored_customer_id)
restored_customer_ids = self.request.session.pop('restored_customer_ids', None)
if restored_customer_ids:
highlighted_customer_ids.extend([int(cid) for cid in restored_customer_ids])
context['highlighted_customer_ids'] = highlighted_customer_ids
context['undo_timeout'] = settings.UNDO_TIMEOUT
context['toast_position'] = settings.TOAST_POSITION
# Activity feed filtering
if self.request.GET.get('clear_filters'):
for key in list(self.request.session.keys()):
if key.startswith('activity_feed_filter_'):
del self.request.session[key]
activity_sort = self.request.GET.get('activity_sort', '-timestamp')
activity_page_size = self.request.GET.get('activity_page_size', 5)
activity_filter_params = {
'activity_user': self.request.GET.get('activity_user'),
'activity_action': self.request.GET.get('activity_action'),
'activity_date_range': self.request.GET.get('activity_date_range'),
'activity_sort': activity_sort,
'activity_page_size': activity_page_size,
}
for key, value in activity_filter_params.items():
if value is not None:
self.request.session[f'activity_feed_filter_{key}'] = value
else:
activity_filter_params[key] = self.request.session.get(f'activity_feed_filter_{key}')
activity_logs = ActivityLog.objects.select_related('actor').prefetch_related('content_object').all()
if activity_filter_params['activity_user']:
activity_logs = activity_logs.filter(actor_id=activity_filter_params['activity_user'])
if activity_filter_params['activity_action']:
activity_logs = activity_logs.filter(action=activity_filter_params['activity_action'])
if activity_filter_params['activity_date_range']:
try:
start_date, end_date = activity_filter_params['activity_date_range'].split(' to ')
activity_logs = activity_logs.filter(timestamp__date__gte=start_date, timestamp__date__lte=end_date)
except ValueError:
# Handle cases where the date range is not in the expected format
pass
if activity_filter_params['activity_sort']:
sort_param = activity_filter_params['activity_sort']
if sort_param == 'actor':
activity_logs = activity_logs.order_by('actor__email')
elif sort_param == '-actor':
activity_logs = activity_logs.order_by('-actor__email')
else:
activity_logs = activity_logs.order_by(sort_param)
# Activity summary
activity_summary = activity_logs.values('action').annotate(count=Count('action'))
context['activity_summary'] = {item['action']: item['count'] for item in activity_summary}
# Paginate activity logs
activity_page_size = activity_filter_params.get('activity_page_size', 5)
activity_paginator = Paginator(activity_logs, activity_page_size)
activity_page_number = self.request.GET.get('activity_page')
activity_page_obj = activity_paginator.get_page(activity_page_number)
context['activity_logs'] = activity_page_obj
context['activity_paginator'] = activity_paginator
context['activity_log_count'] = activity_paginator.count
context['activity_page_size'] = int(activity_page_size)
# Get users and actions for filter dropdowns
user_ids = ActivityLog.objects.values_list('actor_id', flat=True).distinct()
context['activity_users'] = User.objects.filter(id__in=user_ids)
context['activity_actions'] = ActivityLog.objects.values_list('action', flat=True).distinct()
context['selected_activity_user'] = self.request.session.get('activity_feed_filter_activity_user', '')
context['selected_activity_action'] = self.request.session.get('activity_feed_filter_activity_action', '')
context['selected_activity_date_range'] = self.request.session.get('activity_feed_filter_activity_date_range', '')
context['selected_activity_sort'] = self.request.session.get('activity_feed_filter_activity_sort', '-timestamp')
from datetime import timedelta
twenty_four_hours_ago = timezone.now() - timedelta(hours=24)
context['recent_activity_count'] = ActivityLog.objects.filter(timestamp__gte=twenty_four_hours_ago).count()
return context
class CustomerDetailView(View):
template_name = "core/customer_detail.html"
def get(self, request, pk):
customer = get_object_or_404(
Customer.objects.select_related('owner', 'tenant')
.prefetch_related('opportunities', 'leads', 'contacthistory_set', 'notes'),
pk=pk
)
context = {
"customer": customer,
"opportunities": customer.opportunities.all(),
"leads": customer.leads.all(),
"contact_history": customer.contacthistory_set.all(),
"notes": customer.notes.all(),
"opportunity_count": customer.opportunities.count(),
"lead_count": customer.leads.count(),
"note_count": customer.notes.count(),
}
return render(request, self.template_name, context)
from django.urls import reverse_lazy
from django.http import HttpResponseRedirect
from django.contrib import messages
from django.views.generic import DetailView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from .forms import CustomerForm, OpportunityForm
from django.forms.models import model_to_dict
class CustomerUpdateView(UpdateView):
model = Customer
form_class = CustomerForm
template_name = 'core/customer_form.html'
def get_success_url(self):
return reverse_lazy('customer_list')
def form_valid(self, form):
# Capture previous state for changed fields only
previous_state = {key: str(getattr(self.object, key)) for key in form.changed_data}
response = super().form_valid(form)
# Capture new state for changed fields
changed_data = {key: {'old': previous_state[key], 'new': str(form.cleaned_data[key])} for key in form.changed_data}
if form.changed_data:
undo_data = {
'customer_id': self.object.pk,
'previous_state': previous_state,
'timestamp': timezone.now().isoformat(),
'session_key': self.request.session.session_key,
'action': 'update'
}
self.request.session['undo_data'] = undo_data
undo_url = reverse_lazy('customer_undo')
message = f'{{"message": "Customer updated successfully.", "undo_url": "{undo_url}"}}'
messages.success(self.request, message)
ActivityLog.objects.create(
actor=self.request.user,
action=f'updated a {self.object.__class__.__name__}',
content_object=self.object,
details=changed_data
)
return response
def form_invalid(self, form):
messages.error(self.request, 'Please correct the errors below.')
return super().form_invalid(form)
class CustomerDeleteView(DeleteView):
model = Customer
template_name = 'core/customer_confirm_delete.html'
success_url = reverse_lazy('customer_list')
def form_valid(self, form):
self.object = self.get_object()
success_url = self.get_success_url()
# Store data required for restoration
previous_state = model_to_dict(self.object, fields=['name', 'email', 'phone', 'address', 'status', 'owner'])
# model_to_dict gives owner id, which is what we want.
undo_data = {
'customer_id': self.object.pk,
'previous_state': previous_state,
'timestamp': timezone.now().isoformat(),
'session_key': self.request.session.session_key,
'action': 'delete'
}
self.request.session['undo_data'] = undo_data
self.object.delete(user=self.request.user)
undo_url = reverse_lazy('customer_undo')
message = f'{{"message": "Customer deleted successfully.", "undo_url": "{undo_url}"}}'
messages.success(self.request, message)
return HttpResponseRedirect(success_url)
class CustomerUndoView(View):
def get(self, request):
undo_data = request.session.get('undo_data')
if not undo_data or undo_data.get('session_key') != request.session.session_key:
messages.error(request, 'Invalid undo request.')
return HttpResponseRedirect(reverse_lazy('customer_list'))
try:
undo_timestamp = timezone.datetime.fromisoformat(undo_data['timestamp'])
if (timezone.now() - undo_timestamp).total_seconds() > settings.UNDO_TIMEOUT:
messages.error(request, 'The undo link has expired.')
request.session.pop('undo_data', None)
return HttpResponseRedirect(reverse_lazy('customer_list'))
except (ValueError, TypeError):
messages.error(request, 'Invalid undo timestamp.')
request.session.pop('undo_data', None)
return HttpResponseRedirect(reverse_lazy('customer_list'))
action = undo_data.get('action')
customer_id = undo_data.get('customer_id')
previous_state = undo_data.get('previous_state')
try:
if action == 'update':
customer = get_object_or_404(Customer, pk=customer_id)
for key, value in previous_state.items():
# Handle foreign keys
if key == 'owner' and value:
value = get_object_or_404(User, pk=value)
setattr(customer, key, value)
customer.save()
ActivityLog.objects.create(actor=request.user, action='undid update for', content_object=customer)
messages.success(request, '{"message": "Update undone successfully.", "icon": "success"}')
request.session['restored_customer_id'] = customer.pk
elif action == 'delete':
# Check if customer is already restored (e.g., from bulk restore)
if Customer.objects.filter(pk=customer_id, is_deleted=False).exists():
messages.warning(request, 'Customer already restored.')
else:
# If soft-deleted, restore it
if Customer.objects.filter(pk=customer_id).exists():
customer = Customer.objects.get(pk=customer_id)
customer.restore(user=request.user)
# If hard-deleted, recreate it
else:
owner_id = previous_state.pop('owner', None)
if owner_id:
previous_state['owner'] = get_object_or_404(User, pk=owner_id)
customer = Customer.objects.create(pk=customer_id, **previous_state)
ActivityLog.objects.create(actor=request.user, action='undid delete for', content_object=customer)
messages.success(request, '{"message": "1 customer restored successfully.", "icon": "success"}')
request.session['restored_customer_id'] = customer_id
except Exception as e:
messages.error(request, f"An error occurred during undo: {e}")
request.session.pop('undo_data', None)
return HttpResponseRedirect(reverse_lazy('customer_list'))
class CustomerRestoreView(View):
def get(self, request, pk):
customer = get_object_or_404(Customer, pk=pk)
customer.restore(user=request.user)
messages.success(request, '{"message": "1 customer restored successfully.", "icon": "success"}')
request.session['restored_customer_id'] = pk
return HttpResponseRedirect(reverse_lazy('customer_list'))
class CustomerBulkActionView(View):
def post(self, request):
selected_customers = request.POST.getlist('selected_customers')
action = request.POST.get('action')
if action == 'restore':
restored_count = 0
restored_ids = []
for customer_id in selected_customers:
customer = get_object_or_404(Customer, pk=customer_id)
customer.restore(user=request.user)
restored_count += 1
restored_ids.append(customer_id)
if restored_count > 0:
messages.success(request, f'{{"message": "{{restored_count}} customers restored successfully.", "icon": "success"}}')
request.session['restored_customer_ids'] = restored_ids
elif action == 'delete':
deleted_count = 0
deleted_ids = []
for customer_id in selected_customers:
customer = get_object_or_404(Customer, pk=customer_id)
customer.delete(user=request.user)
deleted_count += 1
deleted_ids.append(customer_id)
if deleted_count > 0:
request.session['bulk_deleted_customer_ids'] = deleted_ids
undo_url = reverse_lazy('customer_bulk_undo')
message = f'{{"message": "{deleted_count} customers deleted successfully.", "undo_url": "{undo_url}"}}'
messages.success(self.request, message)
return HttpResponseRedirect(reverse_lazy('customer_list'))
class CustomerBulkUndoView(View):
def get(self, request):
customer_ids = request.session.pop('bulk_deleted_customer_ids', [])
if customer_ids:
restored_count = 0
restored_ids = []
for customer_id in customer_ids:
customer = get_object_or_404(Customer, pk=customer_id)
customer.restore(user=request.user)
restored_count += 1
restored_ids.append(customer_id)
if restored_count > 0:
messages.success(request, f'{{"message": "{{restored_count}} customers restored successfully.", "icon": "success"}}')
request.session['restored_customer_ids'] = restored_ids
return HttpResponseRedirect(reverse_lazy('customer_list'))
class OpportunityListView(ListView):
model = Opportunity
template_name = 'core/opportunity_list.html'
context_object_name = 'opportunities'
paginate_by = 25
def get_queryset(self):
queryset = super().get_queryset().select_related('lead__customer', 'created_by').filter(is_deleted=False)
return queryset
class OpportunityDetailView(DetailView):
model = Opportunity
template_name = 'core/opportunity_detail.html'
context_object_name = 'opportunity'
class OpportunityCreateView(CreateView):
model = Opportunity
form_class = OpportunityForm
template_name = 'core/opportunity_form.html'
def get_success_url(self):
return reverse_lazy('opportunity_detail', kwargs={'pk': self.object.pk})
def form_valid(self, form):
form.instance.created_by = self.request.user
messages.success(self.request, 'Opportunity created successfully.')
return super().form_valid(form)
class OpportunityUpdateView(UpdateView):
model = Opportunity
form_class = OpportunityForm
template_name = 'core/opportunity_form.html'
def get_success_url(self):
return reverse_lazy('opportunity_detail', kwargs={'pk': self.object.pk})
def form_valid(self, form):
form.instance.updated_by = self.request.user
messages.success(self.request, 'Opportunity updated successfully.')
return super().form_valid(form)
class OpportunityDeleteView(DeleteView):
model = Opportunity
template_name = 'core/opportunity_confirm_delete.html'
success_url = reverse_lazy('opportunity_list')
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
success_url = self.get_success_url()
self.object.delete(user=request.user)
messages.success(request, 'Opportunity deleted successfully.')
return HttpResponseRedirect(success_url)

View File

@ -1,3 +1,4 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
Faker==26.0.0

View File

@ -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);
}

1180
static/admin/css/base.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

498
static/admin/css/forms.css Normal file
View File

@ -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;
}

View File

@ -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;
}

View File

@ -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%;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

293
static/admin/css/rtl.css Normal file
View File

@ -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;
}

View File

@ -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;
}

View File

@ -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.

View File

@ -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; }

File diff suppressed because one or more lines are too long

View File

@ -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;
}

20
static/admin/img/LICENSE Normal file
View File

@ -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.

View File

@ -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).

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="15"
height="30"
viewBox="0 0 1792 3584"
version="1.1"
id="svg5"
sodipodi:docname="calendar-icons.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview5"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="13.3"
inkscape:cx="15.526316"
inkscape:cy="20.977444"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" />
<defs
id="defs2">
<g
id="previous">
<path
d="m 1037,1395 102,-102 q 19,-19 19,-45 0,-26 -19,-45 L 832,896 1139,589 q 19,-19 19,-45 0,-26 -19,-45 L 1037,397 q -19,-19 -45,-19 -26,0 -45,19 L 493,851 q -19,19 -19,45 0,26 19,45 l 454,454 q 19,19 45,19 26,0 45,-19 z m 627,-499 q 0,209 -103,385.5 Q 1458,1458 1281.5,1561 1105,1664 896,1664 687,1664 510.5,1561 334,1458 231,1281.5 128,1105 128,896 128,687 231,510.5 334,334 510.5,231 687,128 896,128 1105,128 1281.5,231 1458,334 1561,510.5 1664,687 1664,896 Z"
id="path1" />
</g>
<g
id="next">
<path
d="m 845,1395 454,-454 q 19,-19 19,-45 0,-26 -19,-45 L 845,397 q -19,-19 -45,-19 -26,0 -45,19 L 653,499 q -19,19 -19,45 0,26 19,45 l 307,307 -307,307 q -19,19 -19,45 0,26 19,45 l 102,102 q 19,19 45,19 26,0 45,-19 z m 819,-499 q 0,209 -103,385.5 Q 1458,1458 1281.5,1561 1105,1664 896,1664 687,1664 510.5,1561 334,1458 231,1281.5 128,1105 128,896 128,687 231,510.5 334,334 510.5,231 687,128 896,128 1105,128 1281.5,231 1458,334 1561,510.5 1664,687 1664,896 Z"
id="path2" />
</g>
</defs>
<use
xlink:href="#next"
x="0"
y="5376"
fill="#000000"
id="use5"
transform="translate(0,-3584)" />
<use
xlink:href="#previous"
x="0"
y="0"
fill="#333333"
id="use2"
style="fill:#000000;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1 @@
<svg width="24" height="22" viewBox="0 0 847 779" xmlns="http://www.w3.org/2000/svg"><g><path fill="#EBECE6" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120z"/><path fill="#9E9E93" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120zm607 25h-607c-26 0-50 11-67 28-17 18-28 41-28 67v536c0 27 11 50 28 68 17 17 41 27 67 27h607c26 0 49-10 67-27 17-18 28-41 28-68v-536c0-26-11-49-28-67-18-17-41-28-67-28z"/><path stroke="#A9A8A4" stroke-width="20" d="M706 295l-68 281"/><path stroke="#E47474" stroke-width="20" d="M316 648l390-353M141 435l175 213"/><path stroke="#C9C9C9" stroke-width="20" d="M319 151l-178 284M706 295l-387-144"/><g fill="#040405"><path d="M319 111c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40zM141 395c22 0 40 18 40 40s-18 40-40 40c-23 0-41-18-41-40s18-40 41-40zM316 608c22 0 40 18 40 40 0 23-18 41-40 41s-40-18-40-41c0-22 18-40 40-40zM706 254c22 0 40 18 40 41 0 22-18 40-40 40s-40-18-40-40c0-23 18-41 40-41zM638 536c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg width="24" height="22" viewBox="0 0 847 779" xmlns="http://www.w3.org/2000/svg"><g><path fill="#F1C02A" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120z"/><path fill="#9E9E93" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120zm607 25h-607c-26 0-50 11-67 28-17 18-28 41-28 67v536c0 27 11 50 28 68 17 17 41 27 67 27h607c26 0 49-10 67-27 17-18 28-41 28-68v-536c0-26-11-49-28-67-18-17-41-28-67-28z"/><path stroke="#A9A8A4" stroke-width="20" d="M706 295l-68 281"/><path stroke="#E47474" stroke-width="20" d="M316 648l390-353M141 435l175 213"/><path stroke="#C9A741" stroke-width="20" d="M319 151l-178 284M706 295l-387-144"/><g fill="#040405"><path d="M319 111c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40zM141 395c22 0 40 18 40 40s-18 40-40 40c-23 0-41-18-41-40s18-40 41-40zM316 608c22 0 40 18 40 40 0 23-18 41-40 41s-40-18-40-41c0-22 18-40 40-40zM706 254c22 0 40 18 40 41 0 22-18 40-40 40s-40-18-40-40c0-23 18-41 40-41zM638 536c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#5fa225" d="M1600 796v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#efb80b" d="M1024 1375v-190q0-14-9.5-23.5t-22.5-9.5h-192q-13 0-22.5 9.5t-9.5 23.5v190q0 14 9.5 23.5t22.5 9.5h192q13 0 22.5-9.5t9.5-23.5zm-2-374l18-459q0-12-10-19-13-11-24-11h-220q-11 0-24 11-10 7-10 21l17 457q0 10 10 16.5t24 6.5h185q14 0 23.5-6.5t10.5-16.5zm-14-934l768 1408q35 63-2 126-17 29-46.5 46t-63.5 17h-1536q-34 0-63.5-17t-46.5-46q-37-63-2-126l768-1408q17-31 47-49t65-18 65 18 47 49z"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@ -0,0 +1,9 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M192 1664h288v-288h-288v288zm352 0h320v-288h-320v288zm-352-352h288v-320h-288v320zm352 0h320v-320h-320v320zm-352-384h288v-288h-288v288zm736 736h320v-288h-320v288zm-384-736h320v-288h-320v288zm768 736h288v-288h-288v288zm-384-352h320v-320h-320v320zm-352-864v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm736 864h288v-320h-288v320zm-384-384h320v-288h-320v288zm384 0h288v-288h-288v288zm32-480v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm384-64v1280q0 52-38 90t-90 38h-1408q-52 0-90-38t-38-90v-1280q0-52 38-90t90-38h128v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h384v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h128q52 0 90 38t38 90z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#b48c08" d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1,9 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M1024 544v448q0 14-9 23t-23 9h-320q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224v-352q0-14 9-23t23-9h64q14 0 23 9t9 23zm416 352q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

After

Width:  |  Height:  |  Size: 677 B

View File

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#dd4646" d="M1490 1322q0 40-28 68l-136 136q-28 28-68 28t-68-28l-294-294-294 294q-28 28-68 28t-68-28l-136-136q-28-28-28-68t28-68l294-294-294-294q-28-28-28-68t28-68l136-136q28-28 68-28t68 28l294 294 294-294q28-28 68-28t68 28l136 136q28 28 28 68t-28 68l-294 294 294 294q28 28 28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#2b70bf" d="m555 1335 78-141q-87-63-136-159t-49-203q0-121 61-225-229 117-381 353 167 258 427 375zm389-759q0-20-14-34t-34-14q-125 0-214.5 89.5T592 832q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm363-191q0 7-1 9-105 188-315 566t-316 567l-49 89q-10 16-28 16-12 0-134-70-16-10-16-28 0-12 44-87-143-65-263.5-173T20 1029Q0 998 0 960t20-69q153-235 380-371t496-136q89 0 180 17l54-97q10-16 28-16 5 0 18 6t31 15.5 33 18.5 31.5 18.5T1291 358q16 10 16 27zm37 447q0 139-79 253.5T1056 1250l280-502q8 45 8 84zm448 128q0 35-20 69-39 64-109 145-150 172-347.5 267T896 1536l74-132q212-18 392.5-137T1664 960q-115-179-282-294l63-112q95 64 182.5 153T1772 891q20 34 20 69z"/>
</svg>

After

Width:  |  Height:  |  Size: 784 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#dd4646" d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 560 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1024 1376v-192q0-14-9-23t-23-9h-192q-14 0-23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23-9t9-23zm256-672q0-88-55.5-163t-138.5-116-170-41q-243 0-371 213-15 24 8 42l132 100q7 6 19 6 16 0 25-12 53-68 86-92 34-24 86-24 48 0 85.5 26t37.5 59q0 38-20 61t-68 45q-63 28-115.5 86.5t-52.5 125.5v36q0 14 9 23t23 9h192q14 0 23-9t9-23q0-19 21.5-49.5t54.5-49.5q32-18 49-28.5t46-35 44.5-48 28-60.5 12.5-81zm384 192q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#666666" d="M1024 1376v-192q0-14-9-23t-23-9h-192q-14 0-23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23-9t9-23zm256-672q0-88-55.5-163t-138.5-116-170-41q-243 0-371 213-15 24 8 42l132 100q7 6 19 6 16 0 25-12 53-68 86-92 34-24 86-24 48 0 85.5 26t37.5 59q0 38-20 61t-68 45q-63 28-115.5 86.5t-52.5 125.5v36q0 14 9 23t23 9h192q14 0 23-9t9-23q0-19 21.5-49.5t54.5-49.5q32-18 49-28.5t46-35 44.5-48 28-60.5 12.5-81zm384 192q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#2b70bf" d="M1664 960q-152-236-381-353 61 104 61 225 0 185-131.5 316.5t-316.5 131.5-316.5-131.5-131.5-316.5q0-121 61-225-229 117-381 353 133 205 333.5 326.5t434.5 121.5 434.5-121.5 333.5-326.5zm-720-384q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm848 384q0 34-20 69-140 230-376.5 368.5t-499.5 138.5-499.5-139-376.5-368q-20-35-20-69t20-69q140-229 376.5-368t499.5-139 499.5 139 376.5 368q20 35 20 69z"/>
</svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#70bf2b" d="M1412 734q0-28-18-46l-91-90q-19-19-45-19t-45 19l-408 407-226-226q-19-19-45-19t-45 19l-91 90q-18 18-18 46 0 27 18 45l362 362q19 19 45 19 27 0 46-19l543-543q18-18 18-45zm252 162q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#999999" d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 537 B

View File

@ -0,0 +1,3 @@
<svg width="15" height="15" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#555555" d="M1216 832q0-185-131.5-316.5t-316.5-131.5-316.5 131.5-131.5 316.5 131.5 316.5 316.5 131.5 316.5-131.5 131.5-316.5zm512 832q0 52-38 90t-90 38q-54 0-90-38l-343-342q-179 124-399 124-143 0-273.5-55.5t-225-150-150-225-55.5-273.5 55.5-273.5 150-225 225-150 273.5-55.5 273.5 55.5 225 150 150 225 55.5 273.5q0 220-124 399l343 343q37 37 37 90z"/>
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@ -0,0 +1,34 @@
<svg width="16" height="192" viewBox="0 0 1792 21504" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="up">
<path d="M1412 895q0-27-18-45l-362-362-91-91q-18-18-45-18t-45 18l-91 91-362 362q-18 18-18 45t18 45l91 91q18 18 45 18t45-18l189-189v502q0 26 19 45t45 19h128q26 0 45-19t19-45v-502l189 189q19 19 45 19t45-19l91-91q18-18 18-45zm252 1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="down">
<path d="M1412 897q0-27-18-45l-91-91q-18-18-45-18t-45 18l-189 189v-502q0-26-19-45t-45-19h-128q-26 0-45 19t-19 45v502l-189-189q-19-19-45-19t-45 19l-91 91q-18 18-18 45t18 45l362 362 91 91q18 18 45 18t45-18l91-91 362-362q18-18 18-45zm252-1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="left">
<path d="M1408 960v-128q0-26-19-45t-45-19h-502l189-189q19-19 19-45t-19-45l-91-91q-18-18-45-18t-45 18l-362 362-91 91q-18 18-18 45t18 45l91 91 362 362q18 18 45 18t45-18l91-91q18-18 18-45t-18-45l-189-189h502q26 0 45-19t19-45zm256-64q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="right">
<path d="M1413 896q0-27-18-45l-91-91-362-362q-18-18-45-18t-45 18l-91 91q-18 18-18 45t18 45l189 189h-502q-26 0-45 19t-19 45v128q0 26 19 45t45 19h502l-189 189q-19 19-19 45t19 45l91 91q18 18 45 18t45-18l362-362 91-91q18-18 18-45zm251 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="clearall">
<path transform="translate(336, 336) scale(0.75)" d="M1037 1395l102-102q19-19 19-45t-19-45l-307-307 307-307q19-19 19-45t-19-45l-102-102q-19-19-45-19t-45 19l-454 454q-19 19-19 45t19 45l454 454q19 19 45 19t45-19zm627-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="chooseall">
<path transform="translate(336, 336) scale(0.75)" d="M845 1395l454-454q19-19 19-45t-19-45l-454-454q-19-19-45-19t-45 19l-102 102q-19 19-19 45t19 45l307 307-307 307q-19 19-19 45t19 45l102 102q19 19 45 19t45-19zm819-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#up" x="0" y="0" fill="#666666" />
<use xlink:href="#up" x="0" y="1792" fill="#447e9b" />
<use xlink:href="#down" x="0" y="3584" fill="#666666" />
<use xlink:href="#down" x="0" y="5376" fill="#447e9b" />
<use xlink:href="#left" x="0" y="7168" fill="#666666" />
<use xlink:href="#left" x="0" y="8960" fill="#447e9b" />
<use xlink:href="#right" x="0" y="10752" fill="#666666" />
<use xlink:href="#right" x="0" y="12544" fill="#447e9b" />
<use xlink:href="#clearall" x="0" y="14336" fill="#666666" />
<use xlink:href="#clearall" x="0" y="16128" fill="#447e9b" />
<use xlink:href="#chooseall" x="0" y="17920" fill="#666666" />
<use xlink:href="#chooseall" x="0" y="19712" fill="#447e9b" />
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,19 @@
<svg width="14" height="84" viewBox="0 0 1792 10752" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="sort">
<path d="M1408 1088q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45zm0-384q0 26-19 45t-45 19h-896q-26 0-45-19t-19-45 19-45l448-448q19-19 45-19t45 19l448 448q19 19 19 45z"/>
</g>
<g id="ascending">
<path d="M1408 1216q0 26-19 45t-45 19h-896q-26 0-45-19t-19-45 19-45l448-448q19-19 45-19t45 19l448 448q19 19 19 45z"/>
</g>
<g id="descending">
<path d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z"/>
</g>
</defs>
<use xlink:href="#sort" x="0" y="0" fill="#999999" />
<use xlink:href="#sort" x="0" y="1792" fill="#447e9b" />
<use xlink:href="#ascending" x="0" y="3584" fill="#999999" />
<use xlink:href="#ascending" x="0" y="5376" fill="#447e9b" />
<use xlink:href="#descending" x="0" y="7168" fill="#999999" />
<use xlink:href="#descending" x="0" y="8960" fill="#447e9b" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1363 877l-742 742q-19 19-45 19t-45-19l-166-166q-19-19-19-45t19-45l531-531-531-531q-19-19-19-45t19-45l166-166q19-19 45-19t45 19l742 742q19 19 19 45t-19 45z"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View File

@ -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;
}

View File

@ -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 <p class="info">, 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);
}
}
// <div class="selector"> or <div class="selector stacked">
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';
// <div class="selector-available">
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'
);
// <ul class="selector-chooser">
const selector_chooser = quickElement('ul', selector_div);
selector_chooser.className = 'selector-chooser';
const add_button = quickElement(
'button',
quickElement('li', selector_chooser),
interpolate(gettext('Choose selected %s'), [field_name]),
'id', field_id + '_add',
'class', 'selector-add',
'type', 'button'
);
const remove_button = quickElement(
'button',
quickElement('li', selector_chooser),
interpolate(gettext('Remove selected %s'), [field_name]),
'id', field_id + '_remove',
'class', 'selector-remove',
'type', 'button'
);
// <div class="selector-chosen">
const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen');
selector_chosen.className = 'selector-chosen';
const selector_chosen_title = quickElement('div', selector_chosen);
selector_chosen_title.className = 'selector-chosen-title';
selector_chosen_title.id = field_id + '_to_title';
quickElement('label', selector_chosen_title, interpolate(gettext('Chosen %s') + ' ', [field_name]), 'for', field_id + '_to');
quickElement(
'p',
selector_chosen_title,
interpolate(gettext('Remove %s by selecting them and then select the "Remove" arrow button.'), [field_name]),
'class', 'helptext'
);
const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected');
filter_selected_p.className = 'selector-filter';
const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input');
quickElement(
'span', search_filter_selected_label, '',
'class', 'help-tooltip search-label-icon',
'aria-label', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name])
);
filter_selected_p.appendChild(document.createTextNode(' '));
const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter"));
filter_selected_input.id = field_id + '_selected_input';
quickElement(
'select',
selector_chosen,
'',
'id', field_id + '_to',
'multiple', '',
'size', from_box.size,
'name', from_box.name,
'aria-labelledby', field_id + '_to_title',
'class', 'filtered'
);
const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display');
quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text');
quickElement('span', warning_footer, ' ' + gettext('(click to clear)'), 'class', 'list-footer-display__clear');
const clear_all = quickElement(
'button',
selector_chosen,
interpolate(gettext('Remove all %s'), [field_name]),
'id', field_id + '_remove_all',
'class', 'selector-clearall',
'type', 'button'
);
from_box.name = from_box.name + '_old';
// Set up the JavaScript event handlers for the select box filter interface
const move_selection = function(e, elem, move_func, from, to) {
if (!elem.hasAttribute('disabled')) {
move_func(from, to);
SelectFilter.refresh_icons(field_id);
SelectFilter.refresh_filtered_selects(field_id);
SelectFilter.refresh_filtered_warning(field_id);
}
e.preventDefault();
};
choose_all.addEventListener('click', function(e) {
move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to');
});
add_button.addEventListener('click', function(e) {
move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to');
});
remove_button.addEventListener('click', function(e) {
move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from');
});
clear_all.addEventListener('click', function(e) {
move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from');
});
warning_footer.addEventListener('click', function(e) {
filter_selected_input.value = '';
SelectBox.filter(field_id + '_to', '');
SelectFilter.refresh_filtered_warning(field_id);
SelectFilter.refresh_icons(field_id);
});
filter_input.addEventListener('keypress', function(e) {
SelectFilter.filter_key_press(e, field_id, '_from', '_to');
});
filter_input.addEventListener('keyup', function(e) {
SelectFilter.filter_key_up(e, field_id, '_from');
});
filter_input.addEventListener('keydown', function(e) {
SelectFilter.filter_key_down(e, field_id, '_from', '_to');
});
filter_selected_input.addEventListener('keypress', function(e) {
SelectFilter.filter_key_press(e, field_id, '_to', '_from');
});
filter_selected_input.addEventListener('keyup', function(e) {
SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input');
});
filter_selected_input.addEventListener('keydown', function(e) {
SelectFilter.filter_key_down(e, field_id, '_to', '_from');
});
selector_div.addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT') {
SelectFilter.refresh_icons(field_id);
}
});
selector_div.addEventListener('dblclick', function(e) {
if (e.target.tagName === 'OPTION') {
if (e.target.closest('select').id === field_id + '_to') {
SelectBox.move(field_id + '_to', field_id + '_from');
} else {
SelectBox.move(field_id + '_from', field_id + '_to');
}
SelectFilter.refresh_icons(field_id);
}
});
from_box.closest('form').addEventListener('submit', function() {
SelectBox.filter(field_id + '_to', '');
SelectBox.select_all(field_id + '_to');
});
SelectBox.init(field_id + '_from');
SelectBox.init(field_id + '_to');
// Move selected from_box options to to_box
SelectBox.move(field_id + '_from', field_id + '_to');
// Initial icon refresh
SelectFilter.refresh_icons(field_id);
},
any_selected: function(field) {
// Temporarily add the required attribute and check validity.
field.required = true;
const any_selected = field.checkValidity();
field.required = false;
return any_selected;
},
refresh_filtered_warning: function(field_id) {
const count = SelectBox.get_hidden_node_count(field_id + '_to');
const selector = document.getElementById(field_id + '_selector_chosen');
const warning = document.getElementById(field_id + '_list-footer-display-text');
selector.className = selector.className.replace('selector-chosen--with-filtered', '');
warning.textContent = interpolate(ngettext(
'%s selected option not visible',
'%s selected options not visible',
count
), [count]);
if(count > 0) {
selector.className += ' selector-chosen--with-filtered';
}
},
refresh_filtered_selects: function(field_id) {
SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value);
SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value);
},
refresh_icons: function(field_id) {
const from = document.getElementById(field_id + '_from');
const to = document.getElementById(field_id + '_to');
// Disabled if no items are selected.
document.getElementById(field_id + '_add').disabled = !SelectFilter.any_selected(from);
document.getElementById(field_id + '_remove').disabled = !SelectFilter.any_selected(to);
// Disabled if the corresponding box is empty.
document.getElementById(field_id + '_add_all').disabled = !from.querySelector('option');
document.getElementById(field_id + '_remove_all').disabled = !to.querySelector('option');
},
filter_key_press: function(event, field_id, source, target) {
const source_box = document.getElementById(field_id + source);
// don't submit form if user pressed Enter
if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) {
source_box.selectedIndex = 0;
SelectBox.move(field_id + source, field_id + target);
source_box.selectedIndex = 0;
event.preventDefault();
}
},
filter_key_up: function(event, field_id, source, filter_input) {
const input = filter_input || '_input';
const source_box = document.getElementById(field_id + source);
const temp = source_box.selectedIndex;
SelectBox.filter(field_id + source, document.getElementById(field_id + input).value);
source_box.selectedIndex = temp;
SelectFilter.refresh_filtered_warning(field_id);
SelectFilter.refresh_icons(field_id);
},
filter_key_down: function(event, field_id, source, target) {
const source_box = document.getElementById(field_id + source);
// right key (39) or left key (37)
const direction = source === '_from' ? 39 : 37;
// right arrow -- move across
if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) {
const old_index = source_box.selectedIndex;
SelectBox.move(field_id + source, field_id + target);
SelectFilter.refresh_filtered_selects(field_id);
SelectFilter.refresh_filtered_warning(field_id);
source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index;
return;
}
// down arrow -- wrap around
if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) {
source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1;
}
// up arrow -- wrap around
if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) {
source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1;
}
}
};
window.addEventListener('load', function(e) {
document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) {
const data = el.dataset;
SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10));
});
});
}

204
static/admin/js/actions.js Normal file
View File

@ -0,0 +1,204 @@
/*global gettext, interpolate, ngettext, Actions*/
'use strict';
{
function show(selector) {
document.querySelectorAll(selector).forEach(function(el) {
el.classList.remove('hidden');
});
}
function hide(selector) {
document.querySelectorAll(selector).forEach(function(el) {
el.classList.add('hidden');
});
}
function showQuestion(options) {
hide(options.acrossClears);
show(options.acrossQuestions);
hide(options.allContainer);
}
function showClear(options) {
show(options.acrossClears);
hide(options.acrossQuestions);
document.querySelector(options.actionContainer).classList.remove(options.selectedClass);
show(options.allContainer);
hide(options.counterContainer);
}
function reset(options) {
hide(options.acrossClears);
hide(options.acrossQuestions);
hide(options.allContainer);
show(options.counterContainer);
}
function clearAcross(options) {
reset(options);
const acrossInputs = document.querySelectorAll(options.acrossInput);
acrossInputs.forEach(function(acrossInput) {
acrossInput.value = 0;
});
document.querySelector(options.actionContainer).classList.remove(options.selectedClass);
}
function checker(actionCheckboxes, options, checked) {
if (checked) {
showQuestion(options);
} else {
reset(options);
}
actionCheckboxes.forEach(function(el) {
el.checked = checked;
el.closest('tr').classList.toggle(options.selectedClass, checked);
});
}
function updateCounter(actionCheckboxes, options) {
const sel = Array.from(actionCheckboxes).filter(function(el) {
return el.checked;
}).length;
const counter = document.querySelector(options.counterContainer);
// data-actions-icnt is defined in the generated HTML
// and contains the total amount of objects in the queryset
const actions_icnt = Number(counter.dataset.actionsIcnt);
counter.textContent = interpolate(
ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), {
sel: sel,
cnt: actions_icnt
}, true);
const allToggle = document.getElementById(options.allToggleId);
allToggle.checked = sel === actionCheckboxes.length;
if (allToggle.checked) {
showQuestion(options);
} else {
clearAcross(options);
}
}
const defaults = {
actionContainer: "div.actions",
counterContainer: "span.action-counter",
allContainer: "div.actions span.all",
acrossInput: "div.actions input.select-across",
acrossQuestions: "div.actions span.question",
acrossClears: "div.actions span.clear",
allToggleId: "action-toggle",
selectedClass: "selected"
};
window.Actions = function(actionCheckboxes, options) {
options = Object.assign({}, defaults, options);
let list_editable_changed = false;
let lastChecked = null;
let shiftPressed = false;
document.addEventListener('keydown', (event) => {
shiftPressed = event.shiftKey;
});
document.addEventListener('keyup', (event) => {
shiftPressed = event.shiftKey;
});
document.getElementById(options.allToggleId).addEventListener('click', function(event) {
checker(actionCheckboxes, options, this.checked);
updateCounter(actionCheckboxes, options);
});
document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) {
el.addEventListener('click', function(event) {
event.preventDefault();
const acrossInputs = document.querySelectorAll(options.acrossInput);
acrossInputs.forEach(function(acrossInput) {
acrossInput.value = 1;
});
showClear(options);
});
});
document.querySelectorAll(options.acrossClears + " a").forEach(function(el) {
el.addEventListener('click', function(event) {
event.preventDefault();
document.getElementById(options.allToggleId).checked = false;
clearAcross(options);
checker(actionCheckboxes, options, false);
updateCounter(actionCheckboxes, options);
});
});
function affectedCheckboxes(target, withModifier) {
const multiSelect = (lastChecked && withModifier && lastChecked !== target);
if (!multiSelect) {
return [target];
}
const checkboxes = Array.from(actionCheckboxes);
const targetIndex = checkboxes.findIndex(el => el === target);
const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked);
const startIndex = Math.min(targetIndex, lastCheckedIndex);
const endIndex = Math.max(targetIndex, lastCheckedIndex);
const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex));
return filtered;
};
Array.from(document.getElementById('result_list').tBodies).forEach(function(el) {
el.addEventListener('change', function(event) {
const target = event.target;
if (target.classList.contains('action-select')) {
const checkboxes = affectedCheckboxes(target, shiftPressed);
checker(checkboxes, options, target.checked);
updateCounter(actionCheckboxes, options);
lastChecked = target;
} else {
list_editable_changed = true;
}
});
});
document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) {
if (list_editable_changed) {
const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost."));
if (!confirmed) {
event.preventDefault();
}
}
});
const el = document.querySelector('#changelist-form input[name=_save]');
// The button does not exist if no fields are editable.
if (el) {
el.addEventListener('click', function(event) {
if (document.querySelector('[name=action]').value) {
const text = list_editable_changed
? gettext("You have selected an action, but you havent saved your changes to individual fields yet. Please click OK to save. Youll need to re-run the action.")
: gettext("You have selected an action, and you havent made any changes on individual fields. Youre probably looking for the Go button rather than the Save button.");
if (!confirm(text)) {
event.preventDefault();
}
}
});
}
// Sync counter when navigating to the page, such as through the back
// button.
window.addEventListener('pageshow', (event) => updateCounter(actionCheckboxes, options));
};
// Call function fn when the DOM is loaded and ready. If it is already
// loaded, call the function now.
// http://youmightnotneedjquery.com/#ready
function ready(fn) {
if (document.readyState !== 'loading') {
fn();
} else {
document.addEventListener('DOMContentLoaded', fn);
}
}
ready(function() {
const actionsEls = document.querySelectorAll('tr input.action-select');
if (actionsEls.length > 0) {
Actions(actionsEls);
}
});
}

View File

@ -0,0 +1,408 @@
/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/
// Inserts shortcut buttons after all of the following:
// <input type="text" class="vDateField">
// <input type="text" class="vTimeField">
'use strict';
{
const DateTimeShortcuts = {
calendars: [],
calendarInputs: [],
clockInputs: [],
clockHours: {
default_: [
[gettext_noop('Now'), -1],
[gettext_noop('Midnight'), 0],
[gettext_noop('6 a.m.'), 6],
[gettext_noop('Noon'), 12],
[gettext_noop('6 p.m.'), 18]
]
},
dismissClockFunc: [],
dismissCalendarFunc: [],
calendarDivName1: 'calendarbox', // name of calendar <div> that gets toggled
calendarDivName2: 'calendarin', // name of <div> that contains calendar
calendarLinkName: 'calendarlink', // name of the link that is used to toggle
clockDivName: 'clockbox', // name of clock <div> that gets toggled
clockLinkName: 'clocklink', // name of the link that is used to toggle
shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts
timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch
timezoneOffset: 0,
init: function() {
const serverOffset = document.body.dataset.adminUtcOffset;
if (serverOffset) {
const localOffset = new Date().getTimezoneOffset() * -60;
DateTimeShortcuts.timezoneOffset = localOffset - serverOffset;
}
for (const inp of document.getElementsByTagName('input')) {
if (inp.type === 'text' && inp.classList.contains('vTimeField')) {
DateTimeShortcuts.addClock(inp);
DateTimeShortcuts.addTimezoneWarning(inp);
}
else if (inp.type === 'text' && inp.classList.contains('vDateField')) {
DateTimeShortcuts.addCalendar(inp);
DateTimeShortcuts.addTimezoneWarning(inp);
}
}
},
// Return the current time while accounting for the server timezone.
now: function() {
const serverOffset = document.body.dataset.adminUtcOffset;
if (serverOffset) {
const localNow = new Date();
const localOffset = localNow.getTimezoneOffset() * -60;
localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset));
return localNow;
} else {
return new Date();
}
},
// Add a warning when the time zone in the browser and backend do not match.
addTimezoneWarning: function(inp) {
const warningClass = DateTimeShortcuts.timezoneWarningClass;
let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600;
// Only warn if there is a time zone mismatch.
if (!timezoneOffset) {
return;
}
// Check if warning is already there.
if (inp.parentNode.querySelectorAll('.' + warningClass).length) {
return;
}
let message;
if (timezoneOffset > 0) {
message = ngettext(
'Note: You are %s hour ahead of server time.',
'Note: You are %s hours ahead of server time.',
timezoneOffset
);
}
else {
timezoneOffset *= -1;
message = ngettext(
'Note: You are %s hour behind server time.',
'Note: You are %s hours behind server time.',
timezoneOffset
);
}
message = interpolate(message, [timezoneOffset]);
const warning = document.createElement('div');
warning.classList.add('help', warningClass);
warning.textContent = message;
inp.parentNode.appendChild(warning);
},
// Add clock widget to a given field
addClock: function(inp) {
const num = DateTimeShortcuts.clockInputs.length;
DateTimeShortcuts.clockInputs[num] = inp;
DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; };
// Shortcut links (clock icon and "Now" link)
const shortcuts_span = document.createElement('span');
shortcuts_span.className = DateTimeShortcuts.shortCutsClass;
inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling);
const now_link = document.createElement('a');
now_link.href = "#";
now_link.textContent = gettext('Now');
now_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleClockQuicklink(num, -1);
});
const clock_link = document.createElement('a');
clock_link.href = '#';
clock_link.id = DateTimeShortcuts.clockLinkName + num;
clock_link.addEventListener('click', function(e) {
e.preventDefault();
// avoid triggering the document click handler to dismiss the clock
e.stopPropagation();
DateTimeShortcuts.openClock(num);
});
quickElement(
'span', clock_link, '',
'class', 'clock-icon',
'title', gettext('Choose a Time')
);
shortcuts_span.appendChild(document.createTextNode('\u00A0'));
shortcuts_span.appendChild(now_link);
shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0'));
shortcuts_span.appendChild(clock_link);
// Create clock link div
//
// Markup looks like:
// <div id="clockbox1" class="clockbox module">
// <h2>Choose a time</h2>
// <ul class="timelist">
// <li><a href="#">Now</a></li>
// <li><a href="#">Midnight</a></li>
// <li><a href="#">6 a.m.</a></li>
// <li><a href="#">Noon</a></li>
// <li><a href="#">6 p.m.</a></li>
// </ul>
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
// </div>
const clock_box = document.createElement('div');
clock_box.style.display = 'none';
clock_box.style.position = 'absolute';
clock_box.className = 'clockbox module';
clock_box.id = DateTimeShortcuts.clockDivName + num;
document.body.appendChild(clock_box);
clock_box.addEventListener('click', function(e) { e.stopPropagation(); });
quickElement('h2', clock_box, gettext('Choose a time'));
const time_list = quickElement('ul', clock_box);
time_list.className = 'timelist';
// The list of choices can be overridden in JavaScript like this:
// DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]];
// where name is the name attribute of the <input>.
const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name;
DateTimeShortcuts.clockHours[name].forEach(function(element) {
const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#');
time_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleClockQuicklink(num, element[1]);
});
});
const cancel_p = quickElement('p', clock_box);
cancel_p.className = 'calendar-cancel';
const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#');
cancel_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.dismissClock(num);
});
document.addEventListener('keyup', function(event) {
if (event.which === 27) {
// ESC key closes popup
DateTimeShortcuts.dismissClock(num);
event.preventDefault();
}
});
},
openClock: function(num) {
const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num);
const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num);
// Recalculate the clockbox position
// is it left-to-right or right-to-left layout ?
if (window.getComputedStyle(document.body).direction !== 'rtl') {
clock_box.style.left = findPosX(clock_link) + 17 + 'px';
}
else {
// since style's width is in em, it'd be tough to calculate
// px value of it. let's use an estimated px for now
clock_box.style.left = findPosX(clock_link) - 110 + 'px';
}
clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px';
// Show the clock box
clock_box.style.display = 'block';
document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]);
},
dismissClock: function(num) {
document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none';
document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]);
},
handleClockQuicklink: function(num, val) {
let d;
if (val === -1) {
d = DateTimeShortcuts.now();
}
else {
d = new Date(1970, 1, 1, val, 0, 0, 0);
}
DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]);
DateTimeShortcuts.clockInputs[num].focus();
DateTimeShortcuts.dismissClock(num);
},
// Add calendar widget to a given field.
addCalendar: function(inp) {
const num = DateTimeShortcuts.calendars.length;
DateTimeShortcuts.calendarInputs[num] = inp;
DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; };
// Shortcut links (calendar icon and "Today" link)
const shortcuts_span = document.createElement('span');
shortcuts_span.className = DateTimeShortcuts.shortCutsClass;
inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling);
const today_link = document.createElement('a');
today_link.href = '#';
today_link.appendChild(document.createTextNode(gettext('Today')));
today_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, 0);
});
const cal_link = document.createElement('a');
cal_link.href = '#';
cal_link.id = DateTimeShortcuts.calendarLinkName + num;
cal_link.addEventListener('click', function(e) {
e.preventDefault();
// avoid triggering the document click handler to dismiss the calendar
e.stopPropagation();
DateTimeShortcuts.openCalendar(num);
});
quickElement(
'span', cal_link, '',
'class', 'date-icon',
'title', gettext('Choose a Date')
);
shortcuts_span.appendChild(document.createTextNode('\u00A0'));
shortcuts_span.appendChild(today_link);
shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0'));
shortcuts_span.appendChild(cal_link);
// Create calendarbox div.
//
// Markup looks like:
//
// <div id="calendarbox3" class="calendarbox module">
// <h2>
// <a href="#" class="link-previous">&lsaquo;</a>
// <a href="#" class="link-next">&rsaquo;</a> February 2003
// </h2>
// <div class="calendar" id="calendarin3">
// <!-- (cal) -->
// </div>
// <div class="calendar-shortcuts">
// <a href="#">Yesterday</a> | <a href="#">Today</a> | <a href="#">Tomorrow</a>
// </div>
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
// </div>
const cal_box = document.createElement('div');
cal_box.style.display = 'none';
cal_box.style.position = 'absolute';
cal_box.className = 'calendarbox module';
cal_box.id = DateTimeShortcuts.calendarDivName1 + num;
document.body.appendChild(cal_box);
cal_box.addEventListener('click', function(e) { e.stopPropagation(); });
// next-prev links
const cal_nav = quickElement('div', cal_box);
const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#');
cal_nav_prev.className = 'calendarnav-previous';
cal_nav_prev.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.drawPrev(num);
});
const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#');
cal_nav_next.className = 'calendarnav-next';
cal_nav_next.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.drawNext(num);
});
// main box
const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num);
cal_main.className = 'calendar';
DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num));
DateTimeShortcuts.calendars[num].drawCurrent();
// calendar shortcuts
const shortcuts = quickElement('div', cal_box);
shortcuts.className = 'calendar-shortcuts';
let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, -1);
});
shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0'));
day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, 0);
});
shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0'));
day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, +1);
});
// cancel bar
const cancel_p = quickElement('p', cal_box);
cancel_p.className = 'calendar-cancel';
const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#');
cancel_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.dismissCalendar(num);
});
document.addEventListener('keyup', function(event) {
if (event.which === 27) {
// ESC key closes popup
DateTimeShortcuts.dismissCalendar(num);
event.preventDefault();
}
});
},
openCalendar: function(num) {
const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num);
const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num);
const inp = DateTimeShortcuts.calendarInputs[num];
// Determine if the current value in the input has a valid date.
// If so, draw the calendar with that date's year and month.
if (inp.value) {
const format = get_format('DATE_INPUT_FORMATS')[0];
const selected = inp.value.strptime(format);
const year = selected.getUTCFullYear();
const month = selected.getUTCMonth() + 1;
const re = /\d{4}/;
if (re.test(year.toString()) && month >= 1 && month <= 12) {
DateTimeShortcuts.calendars[num].drawDate(month, year, selected);
}
}
// Recalculate the clockbox position
// is it left-to-right or right-to-left layout ?
if (window.getComputedStyle(document.body).direction !== 'rtl') {
cal_box.style.left = findPosX(cal_link) + 17 + 'px';
}
else {
// since style's width is in em, it'd be tough to calculate
// px value of it. let's use an estimated px for now
cal_box.style.left = findPosX(cal_link) - 180 + 'px';
}
cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px';
cal_box.style.display = 'block';
document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]);
},
dismissCalendar: function(num) {
document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none';
document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]);
},
drawPrev: function(num) {
DateTimeShortcuts.calendars[num].drawPreviousMonth();
},
drawNext: function(num) {
DateTimeShortcuts.calendars[num].drawNextMonth();
},
handleCalendarCallback: function(num) {
const format = get_format('DATE_INPUT_FORMATS')[0];
return function(y, m, d) {
DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format);
DateTimeShortcuts.calendarInputs[num].focus();
document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none';
};
},
handleCalendarQuickLink: function(num, offset) {
const d = DateTimeShortcuts.now();
d.setDate(d.getDate() + offset);
DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]);
DateTimeShortcuts.calendarInputs[num].focus();
DateTimeShortcuts.dismissCalendar(num);
}
};
window.addEventListener('load', DateTimeShortcuts.init);
window.DateTimeShortcuts = DateTimeShortcuts;
}

View File

@ -0,0 +1,252 @@
/*global SelectBox, interpolate*/
// Handles related-objects functionality: lookup link for raw_id_fields
// and Add Another links.
'use strict';
{
const $ = django.jQuery;
let popupIndex = 0;
const relatedWindows = [];
function dismissChildPopups() {
relatedWindows.forEach(function(win) {
if(!win.closed) {
win.dismissChildPopups();
win.close();
}
});
}
function setPopupIndex() {
if(document.getElementsByName("_popup").length > 0) {
const index = window.name.lastIndexOf("__") + 2;
popupIndex = parseInt(window.name.substring(index));
} else {
popupIndex = 0;
}
}
function addPopupIndex(name) {
return name + "__" + (popupIndex + 1);
}
function removePopupIndex(name) {
return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), '');
}
function showAdminPopup(triggeringLink, name_regexp, add_popup) {
const name = addPopupIndex(triggeringLink.id.replace(name_regexp, ''));
const href = new URL(triggeringLink.href);
if (add_popup) {
href.searchParams.set('_popup', 1);
}
const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
relatedWindows.push(win);
win.focus();
return false;
}
function showRelatedObjectLookupPopup(triggeringLink) {
return showAdminPopup(triggeringLink, /^lookup_/, true);
}
function dismissRelatedLookupPopup(win, chosenId) {
const name = removePopupIndex(win.name);
const elem = document.getElementById(name);
if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) {
elem.value += ',' + chosenId;
} else {
elem.value = chosenId;
}
$(elem).trigger('change');
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
function showRelatedObjectPopup(triggeringLink) {
return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false);
}
function updateRelatedObjectLinks(triggeringLink) {
const $this = $(triggeringLink);
const siblings = $this.nextAll('.view-related, .change-related, .delete-related');
if (!siblings.length) {
return;
}
const value = $this.val();
if (value) {
siblings.each(function() {
const elm = $(this);
elm.attr('href', elm.attr('data-href-template').replace('__fk__', value));
elm.removeAttr('aria-disabled');
});
} else {
siblings.removeAttr('href');
siblings.attr('aria-disabled', true);
}
}
function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId, skipIds = []) {
// After create/edit a model from the options next to the current
// select (+ or :pencil:) update ForeignKey PK of the rest of selects
// in the page.
const path = win.location.pathname;
// Extract the model from the popup url '.../<model>/add/' or
// '.../<model>/<id>/change/' depending the action (add or change).
const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)];
// Select elements with a specific model reference and context of "available-source".
const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] [data-context="available-source"]`);
selectsRelated.forEach(function(select) {
if (currentSelect === select || skipIds && skipIds.includes(select.id)) {
return;
}
let option = select.querySelector(`option[value="${objId}"]`);
if (!option) {
option = new Option(newRepr, newId);
select.options.add(option);
// Update SelectBox cache for related fields.
if (window.SelectBox !== undefined && !SelectBox.cache[currentSelect.id]) {
SelectBox.add_to_cache(select.id, option);
SelectBox.redisplay(select.id);
}
return;
}
option.textContent = newRepr;
option.value = newId;
});
}
function dismissAddRelatedObjectPopup(win, newId, newRepr) {
const name = removePopupIndex(win.name);
const elem = document.getElementById(name);
if (elem) {
const elemName = elem.nodeName.toUpperCase();
if (elemName === 'SELECT') {
elem.options[elem.options.length] = new Option(newRepr, newId, true, true);
updateRelatedSelectsOptions(elem, win, null, newRepr, newId);
} else if (elemName === 'INPUT') {
if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) {
elem.value += ',' + newId;
} else {
elem.value = newId;
}
}
// Trigger a change event to update related links if required.
$(elem).trigger('change');
} else {
const toId = name + "_to";
const toElem = document.getElementById(toId);
const o = new Option(newRepr, newId);
SelectBox.add_to_cache(toId, o);
SelectBox.redisplay(toId);
if (toElem && toElem.nodeName.toUpperCase() === 'SELECT') {
const skipIds = [name + "_from"];
updateRelatedSelectsOptions(toElem, win, null, newRepr, newId, skipIds);
}
}
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) {
const id = removePopupIndex(win.name.replace(/^edit_/, ''));
const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]);
const selects = $(selectsSelector);
selects.find('option').each(function() {
if (this.value === objId) {
this.textContent = newRepr;
this.value = newId;
}
}).trigger('change');
updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId);
selects.next().find('.select2-selection__rendered').each(function() {
// The element can have a clear button as a child.
// Use the lastChild to modify only the displayed value.
this.lastChild.textContent = newRepr;
this.title = newRepr;
});
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
function dismissDeleteRelatedObjectPopup(win, objId) {
const id = removePopupIndex(win.name.replace(/^delete_/, ''));
const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]);
const selects = $(selectsSelector);
selects.find('option').each(function() {
if (this.value === objId) {
$(this).remove();
}
}).trigger('change');
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup;
window.dismissRelatedLookupPopup = dismissRelatedLookupPopup;
window.showRelatedObjectPopup = showRelatedObjectPopup;
window.updateRelatedObjectLinks = updateRelatedObjectLinks;
window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup;
window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup;
window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup;
window.dismissChildPopups = dismissChildPopups;
window.relatedWindows = relatedWindows;
// Kept for backward compatibility
window.showAddAnotherPopup = showRelatedObjectPopup;
window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup;
window.addEventListener('unload', function(evt) {
window.dismissChildPopups();
});
$(document).ready(function() {
setPopupIndex();
$("a[data-popup-opener]").on('click', function(event) {
event.preventDefault();
opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener"));
});
$('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) {
e.preventDefault();
if (this.href) {
const event = $.Event('django:show-related', {href: this.href});
$(this).trigger(event);
if (!event.isDefaultPrevented()) {
showRelatedObjectPopup(this);
}
}
});
$('body').on('change', '.related-widget-wrapper select', function(e) {
const event = $.Event('django:update-related');
$(this).trigger(event);
if (!event.isDefaultPrevented()) {
updateRelatedObjectLinks(this);
}
});
$('.related-widget-wrapper select').trigger('change');
$('body').on('click', '.related-lookup', function(e) {
e.preventDefault();
const event = $.Event('django:lookup-related');
$(this).trigger(event);
if (!event.isDefaultPrevented()) {
showRelatedObjectLookupPopup(this);
}
});
});
}

View File

@ -0,0 +1,33 @@
'use strict';
{
const $ = django.jQuery;
$.fn.djangoAdminSelect2 = function() {
$.each(this, function(i, element) {
$(element).select2({
ajax: {
data: (params) => {
return {
term: params.term,
page: params.page,
app_label: element.dataset.appLabel,
model_name: element.dataset.modelName,
field_name: element.dataset.fieldName
};
}
}
});
});
return this;
};
$(function() {
// Initialize all autocomplete widgets except the one in the template
// form used when a new formset is added.
$('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2();
});
document.addEventListener('formset:added', (event) => {
$(event.target).find('.admin-autocomplete').djangoAdminSelect2();
});
}

239
static/admin/js/calendar.js Normal file
View File

@ -0,0 +1,239 @@
/*global gettext, pgettext, get_format, quickElement, removeChildren*/
/*
calendar.js - Calendar functions by Adrian Holovaty
depends on core.js for utility functions like removeChildren or quickElement
*/
'use strict';
{
// CalendarNamespace -- Provides a collection of HTML calendar-related helper functions
const CalendarNamespace = {
monthsOfYear: [
gettext('January'),
gettext('February'),
gettext('March'),
gettext('April'),
gettext('May'),
gettext('June'),
gettext('July'),
gettext('August'),
gettext('September'),
gettext('October'),
gettext('November'),
gettext('December')
],
monthsOfYearAbbrev: [
pgettext('abbrev. month January', 'Jan'),
pgettext('abbrev. month February', 'Feb'),
pgettext('abbrev. month March', 'Mar'),
pgettext('abbrev. month April', 'Apr'),
pgettext('abbrev. month May', 'May'),
pgettext('abbrev. month June', 'Jun'),
pgettext('abbrev. month July', 'Jul'),
pgettext('abbrev. month August', 'Aug'),
pgettext('abbrev. month September', 'Sep'),
pgettext('abbrev. month October', 'Oct'),
pgettext('abbrev. month November', 'Nov'),
pgettext('abbrev. month December', 'Dec')
],
daysOfWeek: [
gettext('Sunday'),
gettext('Monday'),
gettext('Tuesday'),
gettext('Wednesday'),
gettext('Thursday'),
gettext('Friday'),
gettext('Saturday')
],
daysOfWeekAbbrev: [
pgettext('abbrev. day Sunday', 'Sun'),
pgettext('abbrev. day Monday', 'Mon'),
pgettext('abbrev. day Tuesday', 'Tue'),
pgettext('abbrev. day Wednesday', 'Wed'),
pgettext('abbrev. day Thursday', 'Thur'),
pgettext('abbrev. day Friday', 'Fri'),
pgettext('abbrev. day Saturday', 'Sat')
],
daysOfWeekInitial: [
pgettext('one letter Sunday', 'S'),
pgettext('one letter Monday', 'M'),
pgettext('one letter Tuesday', 'T'),
pgettext('one letter Wednesday', 'W'),
pgettext('one letter Thursday', 'T'),
pgettext('one letter Friday', 'F'),
pgettext('one letter Saturday', 'S')
],
firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')),
isLeapYear: function(year) {
return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0));
},
getDaysInMonth: function(month, year) {
let days;
if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) {
days = 31;
}
else if (month === 4 || month === 6 || month === 9 || month === 11) {
days = 30;
}
else if (month === 2 && CalendarNamespace.isLeapYear(year)) {
days = 29;
}
else {
days = 28;
}
return days;
},
draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999
const today = new Date();
const todayDay = today.getDate();
const todayMonth = today.getMonth() + 1;
const todayYear = today.getFullYear();
let todayClass = '';
// Use UTC functions here because the date field does not contain time
// and using the UTC function variants prevent the local time offset
// from altering the date, specifically the day field. For example:
//
// ```
// var x = new Date('2013-10-02');
// var day = x.getDate();
// ```
//
// The day variable above will be 1 instead of 2 in, say, US Pacific time
// zone.
let isSelectedMonth = false;
if (typeof selected !== 'undefined') {
isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month);
}
month = parseInt(month);
year = parseInt(year);
const calDiv = document.getElementById(div_id);
removeChildren(calDiv);
const calTable = document.createElement('table');
quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year);
const tableBody = quickElement('tbody', calTable);
// Draw days-of-week header
let tableRow = quickElement('tr', tableBody);
for (let i = 0; i < 7; i++) {
quickElement('th', tableRow, CalendarNamespace.daysOfWeekInitial[(i + CalendarNamespace.firstDayOfWeek) % 7]);
}
const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay();
const days = CalendarNamespace.getDaysInMonth(month, year);
let nonDayCell;
// Draw blanks before first of month
tableRow = quickElement('tr', tableBody);
for (let i = 0; i < startingPos; i++) {
nonDayCell = quickElement('td', tableRow, ' ');
nonDayCell.className = "nonday";
}
function calendarMonth(y, m) {
function onClick(e) {
e.preventDefault();
callback(y, m, this.textContent);
}
return onClick;
}
// Draw days of month
let currentDay = 1;
for (let i = startingPos; currentDay <= days; i++) {
if (i % 7 === 0 && currentDay !== 1) {
tableRow = quickElement('tr', tableBody);
}
if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) {
todayClass = 'today';
} else {
todayClass = '';
}
// use UTC function; see above for explanation.
if (isSelectedMonth && currentDay === selected.getUTCDate()) {
if (todayClass !== '') {
todayClass += " ";
}
todayClass += "selected";
}
const cell = quickElement('td', tableRow, '', 'class', todayClass);
const link = quickElement('a', cell, currentDay, 'href', '#');
link.addEventListener('click', calendarMonth(year, month));
currentDay++;
}
// Draw blanks after end of month (optional, but makes for valid code)
while (tableRow.childNodes.length < 7) {
nonDayCell = quickElement('td', tableRow, ' ');
nonDayCell.className = "nonday";
}
calDiv.appendChild(calTable);
}
};
// Calendar -- A calendar instance
function Calendar(div_id, callback, selected) {
// div_id (string) is the ID of the element in which the calendar will
// be displayed
// callback (string) is the name of a JavaScript function that will be
// called with the parameters (year, month, day) when a day in the
// calendar is clicked
this.div_id = div_id;
this.callback = callback;
this.today = new Date();
this.currentMonth = this.today.getMonth() + 1;
this.currentYear = this.today.getFullYear();
if (typeof selected !== 'undefined') {
this.selected = selected;
}
}
Calendar.prototype = {
drawCurrent: function() {
CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected);
},
drawDate: function(month, year, selected) {
this.currentMonth = month;
this.currentYear = year;
if(selected) {
this.selected = selected;
}
this.drawCurrent();
},
drawPreviousMonth: function() {
if (this.currentMonth === 1) {
this.currentMonth = 12;
this.currentYear--;
}
else {
this.currentMonth--;
}
this.drawCurrent();
},
drawNextMonth: function() {
if (this.currentMonth === 12) {
this.currentMonth = 1;
this.currentYear++;
}
else {
this.currentMonth++;
}
this.drawCurrent();
},
drawPreviousYear: function() {
this.currentYear--;
this.drawCurrent();
},
drawNextYear: function() {
this.currentYear++;
this.drawCurrent();
}
};
window.Calendar = Calendar;
window.CalendarNamespace = CalendarNamespace;
}

29
static/admin/js/cancel.js Normal file
View File

@ -0,0 +1,29 @@
'use strict';
{
// Call function fn when the DOM is loaded and ready. If it is already
// loaded, call the function now.
// http://youmightnotneedjquery.com/#ready
function ready(fn) {
if (document.readyState !== 'loading') {
fn();
} else {
document.addEventListener('DOMContentLoaded', fn);
}
}
ready(function() {
function handleClick(event) {
event.preventDefault();
const params = new URLSearchParams(window.location.search);
if (params.has('_popup')) {
window.close(); // Close the popup.
} else {
window.history.back(); // Otherwise, go back.
}
}
document.querySelectorAll('.cancel-link').forEach(function(el) {
el.addEventListener('click', handleClick);
});
});
}

View File

@ -0,0 +1,16 @@
'use strict';
{
const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'];
const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName;
if (modelName) {
const form = document.getElementById(modelName + '_form');
for (const element of form.elements) {
// HTMLElement.offsetParent returns null when the element is not
// rendered.
if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) {
element.focus();
break;
}
}
}
}

184
static/admin/js/core.js Normal file
View File

@ -0,0 +1,184 @@
// Core JavaScript helper functions
'use strict';
// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]);
function quickElement() {
const obj = document.createElement(arguments[0]);
if (arguments[2]) {
const textNode = document.createTextNode(arguments[2]);
obj.appendChild(textNode);
}
const len = arguments.length;
for (let i = 3; i < len; i += 2) {
obj.setAttribute(arguments[i], arguments[i + 1]);
}
arguments[1].appendChild(obj);
return obj;
}
// "a" is reference to an object
function removeChildren(a) {
while (a.hasChildNodes()) {
a.removeChild(a.lastChild);
}
}
// ----------------------------------------------------------------------------
// Find-position functions by PPK
// See https://www.quirksmode.org/js/findpos.html
// ----------------------------------------------------------------------------
function findPosX(obj) {
let curleft = 0;
if (obj.offsetParent) {
while (obj.offsetParent) {
curleft += obj.offsetLeft - obj.scrollLeft;
obj = obj.offsetParent;
}
} else if (obj.x) {
curleft += obj.x;
}
return curleft;
}
function findPosY(obj) {
let curtop = 0;
if (obj.offsetParent) {
while (obj.offsetParent) {
curtop += obj.offsetTop - obj.scrollTop;
obj = obj.offsetParent;
}
} else if (obj.y) {
curtop += obj.y;
}
return curtop;
}
//-----------------------------------------------------------------------------
// Date object extensions
// ----------------------------------------------------------------------------
{
Date.prototype.getTwelveHours = function() {
return this.getHours() % 12 || 12;
};
Date.prototype.getTwoDigitMonth = function() {
return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1);
};
Date.prototype.getTwoDigitDate = function() {
return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate();
};
Date.prototype.getTwoDigitTwelveHour = function() {
return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours();
};
Date.prototype.getTwoDigitHour = function() {
return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours();
};
Date.prototype.getTwoDigitMinute = function() {
return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes();
};
Date.prototype.getTwoDigitSecond = function() {
return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds();
};
Date.prototype.getAbbrevDayName = function() {
return typeof window.CalendarNamespace === "undefined"
? '0' + this.getDay()
: window.CalendarNamespace.daysOfWeekAbbrev[this.getDay()];
};
Date.prototype.getFullDayName = function() {
return typeof window.CalendarNamespace === "undefined"
? '0' + this.getDay()
: window.CalendarNamespace.daysOfWeek[this.getDay()];
};
Date.prototype.getAbbrevMonthName = function() {
return typeof window.CalendarNamespace === "undefined"
? this.getTwoDigitMonth()
: window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()];
};
Date.prototype.getFullMonthName = function() {
return typeof window.CalendarNamespace === "undefined"
? this.getTwoDigitMonth()
: window.CalendarNamespace.monthsOfYear[this.getMonth()];
};
Date.prototype.strftime = function(format) {
const fields = {
a: this.getAbbrevDayName(),
A: this.getFullDayName(),
b: this.getAbbrevMonthName(),
B: this.getFullMonthName(),
c: this.toString(),
d: this.getTwoDigitDate(),
H: this.getTwoDigitHour(),
I: this.getTwoDigitTwelveHour(),
m: this.getTwoDigitMonth(),
M: this.getTwoDigitMinute(),
p: (this.getHours() >= 12) ? 'PM' : 'AM',
S: this.getTwoDigitSecond(),
w: '0' + this.getDay(),
x: this.toLocaleDateString(),
X: this.toLocaleTimeString(),
y: ('' + this.getFullYear()).substr(2, 4),
Y: '' + this.getFullYear(),
'%': '%'
};
let result = '', i = 0;
while (i < format.length) {
if (format.charAt(i) === '%') {
result += fields[format.charAt(i + 1)];
++i;
}
else {
result += format.charAt(i);
}
++i;
}
return result;
};
// ----------------------------------------------------------------------------
// String object extensions
// ----------------------------------------------------------------------------
String.prototype.strptime = function(format) {
const split_format = format.split(/[.\-/]/);
const date = this.split(/[.\-/]/);
let i = 0;
let day, month, year;
while (i < split_format.length) {
switch (split_format[i]) {
case "%d":
day = date[i];
break;
case "%m":
month = date[i] - 1;
break;
case "%Y":
year = date[i];
break;
case "%y":
// A %y value in the range of [00, 68] is in the current
// century, while [69, 99] is in the previous century,
// according to the Open Group Specification.
if (parseInt(date[i], 10) >= 69) {
year = date[i];
} else {
year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100;
}
break;
}
++i;
}
// Create Date object from UTC since the parsed value is supposed to be
// in UTC, not local time. Also, the calendar uses UTC functions for
// date extraction.
return new Date(Date.UTC(year, month, day));
};
}

View File

@ -0,0 +1,30 @@
/**
* Persist changelist filters state (collapsed/expanded).
*/
'use strict';
{
// Init filters.
let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState'));
if (!filters) {
filters = {};
}
Object.entries(filters).forEach(([key, value]) => {
const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`);
// Check if the filter is present, it could be from other view.
if (detailElement) {
value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open');
}
});
// Save filter state when clicks.
const details = document.querySelectorAll('details');
details.forEach(detail => {
detail.addEventListener('toggle', event => {
filters[`${event.target.dataset.filterTitle}`] = detail.open;
sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters));
});
});
}

Some files were not shown because too many files have changed in this diff Show More