Migration to django
This commit is contained in:
@@ -6,3 +6,10 @@ docker-compose.yml
|
||||
.dockerignore
|
||||
DATA
|
||||
*.md
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
venv/
|
||||
.venv/
|
||||
.env
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -475,3 +475,11 @@ composer.lock
|
||||
##### Docker
|
||||
.env
|
||||
DATA/
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
venv/
|
||||
.venv/
|
||||
.env
|
||||
52
Dockerfile
52
Dockerfile
@@ -1,48 +1,20 @@
|
||||
# Use FrankenPHP (Caddy + PHP)
|
||||
FROM dunglas/frankenphp
|
||||
FROM python:3.12-slim
|
||||
|
||||
# # Install system dependencies
|
||||
# RUN apt-get update && apt-get install -y \
|
||||
# zip \
|
||||
# unzip \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Configure PHP extensions
|
||||
RUN install-php-extensions gd opcache pdo pdo_sqlite
|
||||
WORKDIR /app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
COPY django_app/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY public_html/ /var/www/html/
|
||||
COPY django_app/ /app/django_app/
|
||||
COPY public_html/ /app/public_html/
|
||||
|
||||
# Copy FrankenPHP (Caddy) configuration
|
||||
COPY docker/Caddyfile /etc/frankenphp/Caddyfile
|
||||
RUN mkdir -p /DATA
|
||||
|
||||
# Create DATA directory with proper permissions
|
||||
RUN mkdir -p /DATA && \
|
||||
chown -R www-data:www-data /DATA && \
|
||||
chmod -R 755 /DATA
|
||||
WORKDIR /app/django_app
|
||||
|
||||
# Set permissions for web directory
|
||||
RUN chown -R www-data:www-data /var/www/html && \
|
||||
chmod -R 755 /var/www/html
|
||||
EXPOSE 8000
|
||||
|
||||
# Configure PHP settings
|
||||
RUN echo "session.cookie_lifetime = 604800" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "session.gc_maxlifetime = 604800" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "upload_max_filesize = 500M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "post_max_size = 500M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "date.timezone = UTC" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "display_errors = off" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.enable = 1" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.memory_consumption = 128" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.interned_strings_buffer = 8" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.max_accelerated_files = 4000" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.revalidate_freq = 60" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.fast_shutdown = 1" >> /usr/local/etc/php/conf.d/custom.ini
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"]
|
||||
|
||||
@@ -1,48 +1,20 @@
|
||||
# Use FrankenPHP (Caddy + PHP)
|
||||
FROM dunglas/frankenphp
|
||||
FROM python:3.12-slim
|
||||
|
||||
# # Install system dependencies
|
||||
# RUN apt-get update && apt-get install -y \
|
||||
# zip \
|
||||
# unzip \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Configure PHP extensions
|
||||
RUN install-php-extensions gd opcache pdo pdo_sqlite
|
||||
WORKDIR /app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
COPY django_app/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY public_html/ /var/www/html/
|
||||
COPY django_app/ /app/django_app/
|
||||
COPY public_html/ /app/public_html/
|
||||
|
||||
# Copy FrankenPHP (Caddy) configuration
|
||||
COPY docker/Caddyfile /etc/frankenphp/Caddyfile
|
||||
RUN mkdir -p /DATA
|
||||
|
||||
# Create DATA directory with proper permissions
|
||||
RUN mkdir -p /DATA && \
|
||||
chown -R www-data:www-data /DATA && \
|
||||
chmod -R 755 /DATA
|
||||
WORKDIR /app/django_app
|
||||
|
||||
# Set permissions for web directory
|
||||
RUN chown -R www-data:www-data /var/www/html && \
|
||||
chmod -R 755 /var/www/html
|
||||
EXPOSE 8000
|
||||
|
||||
# Configure PHP settings
|
||||
RUN echo "session.cookie_lifetime = 604800" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "session.gc_maxlifetime = 604800" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "upload_max_filesize = 500M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "post_max_size = 500M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "date.timezone = UTC" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "display_errors = off" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.enable = 0" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.memory_consumption = 128" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.interned_strings_buffer = 8" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.max_accelerated_files = 4000" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.revalidate_freq = 60" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.fast_shutdown = 1" >> /usr/local/etc/php/conf.d/custom.ini
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"]
|
||||
|
||||
38
django_app/README.md
Normal file
38
django_app/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Axia4 Django
|
||||
|
||||
Migracion inicial de Axia4 a Django reutilizando:
|
||||
|
||||
- la base SQLite existente en `DATA/axia4.sqlite`
|
||||
- los recursos estaticos ya presentes en `public_html/static`
|
||||
- las rutas principales ya migradas a URLs limpias (`/login/`, `/account/`, `/aulatek/`, `/club/`, `/sysadmin/`)
|
||||
|
||||
## Arranque local
|
||||
|
||||
```bash
|
||||
cd /workspaces/Axia4/django_app
|
||||
python -m pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
## Admin de Django
|
||||
|
||||
El panel de administracion queda disponible en `/django-admin/`.
|
||||
|
||||
Si necesitas un usuario administrador de Django:
|
||||
|
||||
```bash
|
||||
cd /workspaces/Axia4/django_app
|
||||
python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
- `AXIA4_DB_PATH`: ruta a la base de datos SQLite. Si no se define, se intenta usar `/DATA/axia4.sqlite` y luego `../DATA/axia4.sqlite`.
|
||||
- `AXIA4_DATA_ROOT`: raiz de datos. Por defecto `../DATA`.
|
||||
- `DJANGO_DEBUG`: `1` o `0`.
|
||||
- `DJANGO_SECRET_KEY`: clave de Django.
|
||||
|
||||
## Alcance de esta base
|
||||
|
||||
Esta version levanta la estructura principal de la plataforma en Django con el mismo shell visual y acceso a los datos actuales. Incluye login propio, cuenta, AulaTek, Club y SysAdmin, y deja preparada la base para seguir portando modulos especificos.
|
||||
0
django_app/account/__init__.py
Normal file
0
django_app/account/__init__.py
Normal file
6
django_app/account/apps.py
Normal file
6
django_app/account/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "account"
|
||||
11
django_app/account/urls.py
Normal file
11
django_app/account/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "account"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.account, name="account"),
|
||||
path("register/", views.account_register, name="account_register"),
|
||||
]
|
||||
53
django_app/account/views.py
Normal file
53
django_app/account/views.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import hashlib
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import render
|
||||
|
||||
from core.auth import list_user_sessions, organization_name_map, parse_user_agent
|
||||
from core.shell import require_axia_login, shell_context
|
||||
|
||||
|
||||
def account(request: HttpRequest):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
user = request.axia_user
|
||||
sessions = []
|
||||
current_token = request.session.session_key
|
||||
current_hash = current_token and hashlib.sha256(current_token.encode("utf-8")).hexdigest()
|
||||
name_map = organization_name_map()
|
||||
for session in list_user_sessions(user.username):
|
||||
ua = parse_user_agent(session.user_agent)
|
||||
sessions.append(
|
||||
{
|
||||
"session": session,
|
||||
"ua": ua,
|
||||
"label": f"{ua['browser']} - {ua['os']}",
|
||||
"current": session.session_token == current_hash,
|
||||
}
|
||||
)
|
||||
|
||||
context = shell_context(request, "account", "Mi Cuenta")
|
||||
context.update(
|
||||
{
|
||||
"organization_name": name_map.get(user.active_org, user.active_org),
|
||||
"connected_sessions": sessions,
|
||||
}
|
||||
)
|
||||
return render(request, "core/account.html", context)
|
||||
|
||||
|
||||
def account_register(request: HttpRequest):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
context = shell_context(request, "account", "Crear cuenta")
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Crear cuenta",
|
||||
"module_description": "El registro todavia no esta portado a Django.",
|
||||
"tiles": [],
|
||||
}
|
||||
)
|
||||
return render(request, "core/module.html", context)
|
||||
0
django_app/aulatek/__init__.py
Normal file
0
django_app/aulatek/__init__.py
Normal file
6
django_app/aulatek/apps.py
Normal file
6
django_app/aulatek/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AulatekConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "aulatek"
|
||||
29
django_app/aulatek/migrations/0001_initial.py
Normal file
29
django_app/aulatek/migrations/0001_initial.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
import aulatek.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="StudentAttachment",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("org_id", models.TextField()),
|
||||
("aulario_id", models.TextField()),
|
||||
("alumno", models.TextField()),
|
||||
("data", models.JSONField(blank=True, default=dict)),
|
||||
("photo", models.FileField(blank=True, null=True, upload_to=aulatek.models.student_photo_upload_to)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "aulatek_student_attachments",
|
||||
"unique_together": {("org_id", "aulario_id", "alumno")},
|
||||
},
|
||||
),
|
||||
]
|
||||
34
django_app/aulatek/migrations/0002_comedormenu.py
Normal file
34
django_app/aulatek/migrations/0002_comedormenu.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
import aulatek.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("aulatek", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ComedorMenu",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("org_id", models.TextField()),
|
||||
("aulario_id", models.TextField()),
|
||||
("menu_name", models.TextField()),
|
||||
("first_name", models.TextField()),
|
||||
("first_photo", models.FileField(blank=True, null=True, upload_to=aulatek.models.comedor_first_photo_upload_to)),
|
||||
("second_name", models.TextField()),
|
||||
("second_photo", models.FileField(blank=True, null=True, upload_to=aulatek.models.comedor_second_photo_upload_to)),
|
||||
("dessert_name", models.TextField()),
|
||||
("dessert_photo", models.FileField(blank=True, null=True, upload_to=aulatek.models.comedor_dessert_photo_upload_to)),
|
||||
("created_by", models.TextField(default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "aulatek_comedor_menus",
|
||||
"unique_together": {("org_id", "aulario_id", "menu_name")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
("aulatek", "0002_comedormenu"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="comedormenu",
|
||||
name="shared_aularios",
|
||||
field=models.ManyToManyField(blank=True, related_name="shared_comedor_menus", to="core.aulario"),
|
||||
),
|
||||
]
|
||||
35
django_app/aulatek/migrations/0004_comedormenu_date_type.py
Normal file
35
django_app/aulatek/migrations/0004_comedormenu_date_type.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from django.db import migrations, models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def forward_fill_menu_type_date(apps, schema_editor):
|
||||
ComedorMenu = apps.get_model("aulatek", "ComedorMenu")
|
||||
today = timezone.localdate()
|
||||
for row in ComedorMenu.objects.all().iterator():
|
||||
row.menu_type = (row.menu_name or "Basal").strip() or "Basal"
|
||||
row.menu_date = today
|
||||
row.save(update_fields=["menu_type", "menu_date"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("aulatek", "0003_comedormenu_shared_aularios"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="comedormenu",
|
||||
name="menu_date",
|
||||
field=models.DateField(default=timezone.localdate),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="comedormenu",
|
||||
name="menu_type",
|
||||
field=models.TextField(default="Basal"),
|
||||
),
|
||||
migrations.RunPython(forward_fill_menu_type_date, migrations.RunPython.noop),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="comedormenu",
|
||||
unique_together={("org_id", "aulario_id", "menu_date", "menu_type")},
|
||||
),
|
||||
]
|
||||
70
django_app/aulatek/migrations/0005_comedormenutype.py
Normal file
70
django_app/aulatek/migrations/0005_comedormenutype.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forward_fill_menu_types(apps, schema_editor):
|
||||
ComedorMenu = apps.get_model("aulatek", "ComedorMenu")
|
||||
ComedorMenuType = apps.get_model("aulatek", "ComedorMenuType")
|
||||
Aulario = apps.get_model("core", "Aulario")
|
||||
|
||||
# Build one type row per (org, source_aulario, menu_type).
|
||||
seen = set()
|
||||
for menu in ComedorMenu.objects.all().iterator():
|
||||
key = (menu.org_id, menu.aulario_id, menu.menu_type)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ComedorMenuType.objects.get_or_create(
|
||||
org_id=menu.org_id,
|
||||
source_aulario_id=menu.aulario_id,
|
||||
type_id=menu.menu_type,
|
||||
defaults={
|
||||
"label": menu.menu_type,
|
||||
"color": "#0d6efd",
|
||||
"created_by": menu.created_by or "",
|
||||
},
|
||||
)
|
||||
|
||||
# Promote existing per-date sharing to type-level sharing (union of all dates).
|
||||
for type_row in ComedorMenuType.objects.all().iterator():
|
||||
shared_ids = set()
|
||||
menus = ComedorMenu.objects.filter(
|
||||
org_id=type_row.org_id,
|
||||
aulario_id=type_row.source_aulario_id,
|
||||
menu_type=type_row.type_id,
|
||||
)
|
||||
for menu in menus:
|
||||
shared_ids.update(menu.shared_aularios.values_list("id", flat=True))
|
||||
|
||||
if shared_ids:
|
||||
rows = Aulario.objects.filter(id__in=list(shared_ids))
|
||||
type_row.shared_aularios.set(rows)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
("aulatek", "0004_comedormenu_date_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ComedorMenuType",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("org_id", models.TextField()),
|
||||
("source_aulario_id", models.TextField()),
|
||||
("type_id", models.TextField()),
|
||||
("label", models.TextField()),
|
||||
("color", models.TextField(default="#0d6efd")),
|
||||
("created_by", models.TextField(default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("shared_aularios", models.ManyToManyField(blank=True, related_name="shared_comedor_menu_types", to="core.aulario")),
|
||||
],
|
||||
options={
|
||||
"db_table": "aulatek_comedor_menu_types",
|
||||
"unique_together": {("org_id", "source_aulario_id", "type_id")},
|
||||
},
|
||||
),
|
||||
migrations.RunPython(forward_fill_menu_types, migrations.RunPython.noop),
|
||||
]
|
||||
0
django_app/aulatek/migrations/__init__.py
Normal file
0
django_app/aulatek/migrations/__init__.py
Normal file
102
django_app/aulatek/models.py
Normal file
102
django_app/aulatek/models.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def _safe_segment(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9._-]", "", (value or ""))
|
||||
|
||||
|
||||
def student_photo_upload_to(instance, filename: str) -> str:
|
||||
ext = Path(filename or "").suffix.lower() or ".bin"
|
||||
alumno = _safe_segment(instance.alumno) or "alumno"
|
||||
org_id = _safe_segment(instance.org_id) or "org"
|
||||
aulario_id = _safe_segment(instance.aulario_id) or "aulario"
|
||||
return f"aulatek/{org_id}/{aulario_id}/{alumno}{ext}"
|
||||
|
||||
|
||||
def _comedor_photo_upload_to(instance, filename: str, slot: str) -> str:
|
||||
ext = Path(filename or "").suffix.lower() or ".bin"
|
||||
org_id = _safe_segment(instance.org_id) or "org"
|
||||
aulario_id = _safe_segment(instance.aulario_id) or "aulario"
|
||||
menu_type = _safe_segment(getattr(instance, "menu_type", "")) or "menu"
|
||||
menu_date = _safe_segment(str(getattr(instance, "menu_date", ""))) or "sin-fecha"
|
||||
slot_name = _safe_segment(slot) or "plato"
|
||||
return f"aulatek/{org_id}/{aulario_id}/comedor/{menu_date}/{menu_type}/{slot_name}{ext}"
|
||||
|
||||
|
||||
def comedor_first_photo_upload_to(instance, filename: str) -> str:
|
||||
return _comedor_photo_upload_to(instance, filename, "primero")
|
||||
|
||||
|
||||
def comedor_second_photo_upload_to(instance, filename: str) -> str:
|
||||
return _comedor_photo_upload_to(instance, filename, "segundo")
|
||||
|
||||
|
||||
def comedor_dessert_photo_upload_to(instance, filename: str) -> str:
|
||||
return _comedor_photo_upload_to(instance, filename, "postre")
|
||||
|
||||
|
||||
class StudentAttachment(models.Model):
|
||||
org_id = models.TextField()
|
||||
aulario_id = models.TextField()
|
||||
alumno = models.TextField()
|
||||
data = models.JSONField(default=dict, blank=True)
|
||||
photo = models.FileField(upload_to=student_photo_upload_to, blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "aulatek_student_attachments"
|
||||
unique_together = (("org_id", "aulario_id", "alumno"),)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.org_id}/{self.aulario_id}/{self.alumno}"
|
||||
|
||||
|
||||
class ComedorMenu(models.Model):
|
||||
org_id = models.TextField()
|
||||
aulario_id = models.TextField()
|
||||
menu_name = models.TextField()
|
||||
menu_type = models.TextField(default="Basal")
|
||||
menu_date = models.DateField(default=timezone.localdate)
|
||||
|
||||
first_name = models.TextField()
|
||||
first_photo = models.FileField(upload_to=comedor_first_photo_upload_to, blank=True, null=True)
|
||||
second_name = models.TextField()
|
||||
second_photo = models.FileField(upload_to=comedor_second_photo_upload_to, blank=True, null=True)
|
||||
dessert_name = models.TextField()
|
||||
dessert_photo = models.FileField(upload_to=comedor_dessert_photo_upload_to, blank=True, null=True)
|
||||
shared_aularios = models.ManyToManyField("core.Aulario", blank=True, related_name="shared_comedor_menus")
|
||||
|
||||
created_by = models.TextField(default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "aulatek_comedor_menus"
|
||||
unique_together = (("org_id", "aulario_id", "menu_date", "menu_type"),)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.org_id}/{self.aulario_id}/{self.menu_date}/{self.menu_type}"
|
||||
|
||||
|
||||
class ComedorMenuType(models.Model):
|
||||
org_id = models.TextField()
|
||||
source_aulario_id = models.TextField()
|
||||
type_id = models.TextField()
|
||||
label = models.TextField()
|
||||
color = models.TextField(default="#0d6efd")
|
||||
shared_aularios = models.ManyToManyField("core.Aulario", blank=True, related_name="shared_comedor_menu_types")
|
||||
created_by = models.TextField(default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "aulatek_comedor_menu_types"
|
||||
unique_together = (("org_id", "source_aulario_id", "type_id"),)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.org_id}/{self.source_aulario_id}/{self.type_id}"
|
||||
20
django_app/aulatek/urls.py
Normal file
20
django_app/aulatek/urls.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "aulatek"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.aulatek_home, name="aulatek_home"),
|
||||
path("aulario/", views.aulatek_aulario, name="aulatek_aulario"),
|
||||
path("paneldiario/", views.paneldiario, name="paneldiario"),
|
||||
path("alumnos/", views.alumnos, name="alumnos"),
|
||||
path("alumnos/new/", views.alumno_new, name="alumno_new"),
|
||||
path("alumnos/edit/<int:student_id>/", views.alumno_edit, name="alumno_edit"),
|
||||
path("alumnos/delete/<int:student_id>/", views.alumno_delete, name="alumno_delete"),
|
||||
path("alumnos/<int:student_id>/foto/", views.alumno_photo, name="alumno_photo"),
|
||||
path("comedor/", views.comedor, name="comedor"),
|
||||
path("proyectos/", views.proyectos, name="proyectos"),
|
||||
path("supercafe/", views.supercafe, name="supercafe"),
|
||||
]
|
||||
600
django_app/aulatek/views.py
Normal file
600
django_app/aulatek/views.py
Normal file
@@ -0,0 +1,600 @@
|
||||
import re
|
||||
import mimetypes
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.http import FileResponse, Http404, HttpRequest, HttpResponseForbidden
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from core.models import Aulario
|
||||
from core.shell import require_axia_login, shell_context
|
||||
|
||||
from .models import ComedorMenu, ComedorMenuType, StudentAttachment
|
||||
|
||||
|
||||
def _safe_organization_id(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9._-]", "", (value or ""))
|
||||
|
||||
|
||||
def _safe_aulario_id(value: str) -> str:
|
||||
value = (value or "").split("/")[-1].split("\\")[-1]
|
||||
return re.sub(r"[^A-Za-z0-9._-]", "", value)
|
||||
|
||||
|
||||
def _safe_alumno_name(value: str) -> str:
|
||||
value = (value or "").split("/")[-1].split("\\")[-1].strip()
|
||||
value = re.sub(r"[\x00-\x1F\x7F]", "", value)
|
||||
value = value.replace("/", "").replace("\\", "")
|
||||
return value
|
||||
|
||||
|
||||
def _valid_image(uploaded_file) -> bool:
|
||||
content_type = (getattr(uploaded_file, "content_type", "") or "").lower()
|
||||
if not content_type.startswith("image/"):
|
||||
return False
|
||||
allowed_ext = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
ext = Path(uploaded_file.name or "").suffix.lower()
|
||||
return ext in allowed_ext
|
||||
|
||||
|
||||
def _alumnos_permission(request: HttpRequest) -> bool:
|
||||
return (
|
||||
request.axia_user.has_permission("aulatek:admin")
|
||||
or request.axia_user.has_permission("aulatek:docente")
|
||||
or request.axia_user.has_permission("entreaulas:docente")
|
||||
)
|
||||
|
||||
|
||||
def _alumnos_access(request: HttpRequest):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate, None, None
|
||||
if not _alumnos_permission(request):
|
||||
return HttpResponseForbidden("No tienes permiso para gestionar alumnos."), None, None
|
||||
org_id = _safe_organization_id(request.axia_user.active_org)
|
||||
if not org_id:
|
||||
return None, None, None
|
||||
return None, request.axia_user, org_id
|
||||
|
||||
|
||||
def _comedor_access(request: HttpRequest):
|
||||
gate, user, org_id = _alumnos_access(request)
|
||||
if gate:
|
||||
return gate, None, None, None
|
||||
aulario_id = _safe_aulario_id(request.GET.get("aulario", ""))
|
||||
if not aulario_id or not org_id:
|
||||
return None, user, org_id, None
|
||||
aulario_row = Aulario.objects.filter(org_id=org_id, aulario_id=aulario_id).first()
|
||||
if not aulario_row:
|
||||
raise Http404("Aulario no encontrado")
|
||||
return None, user, org_id, aulario_row
|
||||
|
||||
|
||||
def _parse_menu_date(value: str):
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def aulatek_home(request: HttpRequest):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
user = request.axia_user
|
||||
aularios = []
|
||||
if user.active_org:
|
||||
rows = Aulario.objects.filter(org_id=user.active_org).order_by("aulario_id")
|
||||
if user.aulas:
|
||||
rows = rows.filter(aulario_id__in=user.aulas)
|
||||
for row in rows:
|
||||
payload = row.payload
|
||||
if row.icon_file:
|
||||
icon_url = row.icon_file.url
|
||||
elif payload.get("icon"):
|
||||
icon_url = payload.get("icon")
|
||||
else:
|
||||
icon_url = "/static/arasaac/aulario.png"
|
||||
aularios.append(
|
||||
{
|
||||
"id": row.aulario_id,
|
||||
"name": payload.get("name") or row.aulario_id,
|
||||
"icon": icon_url,
|
||||
}
|
||||
)
|
||||
|
||||
context = shell_context(request, "aulatek", "AulaTek")
|
||||
context["aularios"] = aularios
|
||||
return render(request, "aulatek/index.html", context)
|
||||
|
||||
|
||||
def aulatek_aulario(request: HttpRequest):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
aulario_id = (request.GET.get("id") or "").strip()
|
||||
row = Aulario.objects.filter(org_id=request.axia_user.active_org, aulario_id=aulario_id).first()
|
||||
if not row:
|
||||
raise Http404("Aulario no encontrado")
|
||||
|
||||
payload = row.payload
|
||||
context = shell_context(request, "aulatek", f"Aulario: {payload.get('name') or aulario_id}")
|
||||
context.update(
|
||||
{
|
||||
"aulario": {"id": aulario_id, "name": payload.get("name") or aulario_id},
|
||||
"tiles": [
|
||||
{"href": f"/aulatek/paneldiario/?aulario={aulario_id}", "label": "Panel Diario", "icon": "/static/arasaac/pdi.png", "variant": "primary"},
|
||||
{
|
||||
"href": f"/aulatek/alumnos/?aulario={aulario_id}",
|
||||
"label": "Gestion de Alumnos",
|
||||
"icon": "/static/arasaac/alumnos.png",
|
||||
"variant": "info",
|
||||
"visible": _alumnos_permission(request),
|
||||
},
|
||||
{
|
||||
"href": f"/sysadmin/aularios/?aulario={aulario_id}",
|
||||
"label": "Cambiar Ajustes",
|
||||
"icon": "/static/iconexperience/gear_edit.png",
|
||||
"variant": "secondary",
|
||||
"visible": request.axia_user.has_permission("sysadmin:access"),
|
||||
},
|
||||
{"href": f"/aulatek/comedor/?aulario={aulario_id}", "label": "Menu del Comedor", "icon": "/static/arasaac/comedor.png", "variant": "success"},
|
||||
{"href": f"/aulatek/proyectos/?aulario={aulario_id}", "label": "Proyectos", "icon": "", "variant": "warning"},
|
||||
],
|
||||
}
|
||||
)
|
||||
return render(request, "aulatek/aulario.html", context)
|
||||
|
||||
|
||||
def _placeholder(request: HttpRequest, title: str):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
context = shell_context(request, "aulatek", title)
|
||||
context.update({"module_title": title, "module_description": "Modulo en migracion a Django.", "tiles": []})
|
||||
return render(request, "core/module.html", context)
|
||||
|
||||
|
||||
def paneldiario(request: HttpRequest):
|
||||
return _placeholder(request, "Panel Diario")
|
||||
|
||||
|
||||
def alumnos(request: HttpRequest):
|
||||
gate, _, org_id = _alumnos_access(request)
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
aulario_id = _safe_aulario_id(request.GET.get("aulario", ""))
|
||||
if not aulario_id or not org_id:
|
||||
context = shell_context(request, "aulatek", "Gestion de Alumnos")
|
||||
context.update({"module_title": "Gestion de Alumnos", "aulario_id": "", "students": []})
|
||||
return render(request, "aulatek/alumnos.html", context)
|
||||
|
||||
if not Aulario.objects.filter(org_id=org_id, aulario_id=aulario_id).exists():
|
||||
raise Http404("Aulario no encontrado")
|
||||
|
||||
students = []
|
||||
rows = StudentAttachment.objects.filter(org_id=org_id, aulario_id=aulario_id).order_by("alumno")
|
||||
for row in rows:
|
||||
students.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"name": row.alumno,
|
||||
"has_photo": bool(row.photo),
|
||||
}
|
||||
)
|
||||
|
||||
context = shell_context(request, "aulatek", "Gestion de Alumnos")
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Gestion de Alumnos",
|
||||
"aulario_id": aulario_id,
|
||||
"students": students,
|
||||
}
|
||||
)
|
||||
return render(request, "aulatek/alumnos.html", context)
|
||||
|
||||
|
||||
def alumno_new(request: HttpRequest):
|
||||
gate, _, org_id = _alumnos_access(request)
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
aulario_id = _safe_aulario_id(request.GET.get("aulario", ""))
|
||||
if not aulario_id or not org_id:
|
||||
messages.error(request, "No se ha indicado un aulario valido.")
|
||||
return redirect("/aulatek/")
|
||||
if not Aulario.objects.filter(org_id=org_id, aulario_id=aulario_id).exists():
|
||||
raise Http404("Aulario no encontrado")
|
||||
|
||||
if request.method == "POST":
|
||||
nombre = _safe_alumno_name(request.POST.get("nombre", ""))
|
||||
if not nombre:
|
||||
messages.error(request, "El nombre no puede estar vacio.")
|
||||
return redirect(f"/aulatek/alumnos/new/?aulario={aulario_id}")
|
||||
if StudentAttachment.objects.filter(org_id=org_id, aulario_id=aulario_id, alumno=nombre).exists():
|
||||
messages.error(request, "Ya existe un alumno con ese nombre.")
|
||||
return redirect(f"/aulatek/alumnos/new/?aulario={aulario_id}")
|
||||
|
||||
student = StudentAttachment(org_id=org_id, aulario_id=aulario_id, alumno=nombre, data={})
|
||||
photo = request.FILES.get("photo")
|
||||
if photo and _valid_image(photo):
|
||||
student.photo = photo
|
||||
elif photo:
|
||||
messages.warning(request, "La foto no se ha guardado porque no era una imagen valida.")
|
||||
student.save()
|
||||
messages.success(request, "Alumno anadido correctamente.")
|
||||
return redirect(f"/aulatek/alumnos/?aulario={aulario_id}")
|
||||
|
||||
context = shell_context(request, "aulatek", "Nuevo Alumno")
|
||||
context.update({"module_title": "Nuevo Alumno", "aulario_id": aulario_id})
|
||||
return render(request, "aulatek/alumnos_new.html", context)
|
||||
|
||||
|
||||
def alumno_edit(request: HttpRequest, student_id: int):
|
||||
gate, _, org_id = _alumnos_access(request)
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
student = StudentAttachment.objects.filter(id=student_id, org_id=org_id).first()
|
||||
if not student:
|
||||
raise Http404("Alumno no encontrado")
|
||||
|
||||
if request.method == "POST":
|
||||
nombre_new = _safe_alumno_name(request.POST.get("nombre_new", ""))
|
||||
if not nombre_new:
|
||||
messages.error(request, "Nombre invalido.")
|
||||
return redirect(f"/aulatek/alumnos/edit/{student.id}/")
|
||||
|
||||
if nombre_new != student.alumno and StudentAttachment.objects.filter(org_id=org_id, aulario_id=student.aulario_id, alumno=nombre_new).exists():
|
||||
messages.error(request, "Ya existe un alumno con ese nombre.")
|
||||
return redirect(f"/aulatek/alumnos/edit/{student.id}/")
|
||||
|
||||
student.alumno = nombre_new
|
||||
|
||||
photo = request.FILES.get("photo")
|
||||
if photo and _valid_image(photo):
|
||||
if student.photo:
|
||||
student.photo.delete(save=False)
|
||||
student.photo = photo
|
||||
elif photo:
|
||||
messages.warning(request, "La foto no se ha guardado porque no era una imagen valida.")
|
||||
|
||||
student.save()
|
||||
messages.success(request, "Alumno actualizado correctamente.")
|
||||
return redirect(f"/aulatek/alumnos/?aulario={student.aulario_id}")
|
||||
|
||||
context = shell_context(request, "aulatek", f"Editar Alumno: {student.alumno}")
|
||||
context.update({"module_title": "Editar Alumno", "student": student})
|
||||
return render(request, "aulatek/alumnos_edit.html", context)
|
||||
|
||||
|
||||
def alumno_delete(request: HttpRequest, student_id: int):
|
||||
gate, _, org_id = _alumnos_access(request)
|
||||
if gate:
|
||||
return gate
|
||||
if request.method != "POST":
|
||||
return HttpResponseForbidden("Metodo no permitido.")
|
||||
|
||||
student = StudentAttachment.objects.filter(id=student_id, org_id=org_id).first()
|
||||
if not student:
|
||||
raise Http404("Alumno no encontrado")
|
||||
|
||||
aulario_id = student.aulario_id
|
||||
if student.photo:
|
||||
student.photo.delete(save=False)
|
||||
student.delete()
|
||||
messages.success(request, "Alumno eliminado correctamente.")
|
||||
return redirect(f"/aulatek/alumnos/?aulario={aulario_id}")
|
||||
|
||||
|
||||
def alumno_photo(request: HttpRequest, student_id: int):
|
||||
gate, _, org_id = _alumnos_access(request)
|
||||
if gate:
|
||||
return gate
|
||||
if not org_id:
|
||||
raise Http404("Foto no encontrada")
|
||||
|
||||
student = StudentAttachment.objects.filter(id=student_id, org_id=org_id).first()
|
||||
if not student:
|
||||
raise Http404("Foto no encontrada")
|
||||
if not student.photo:
|
||||
raise Http404("Foto no encontrada")
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(student.photo.name)
|
||||
return FileResponse(student.photo.open("rb"), content_type=mime_type or "application/octet-stream")
|
||||
|
||||
|
||||
def comedor(request: HttpRequest):
|
||||
gate, user, org_id, aulario_row = _comedor_access(request)
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
context = shell_context(request, "aulatek", "Menu del Comedor")
|
||||
if not org_id or not aulario_row:
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Menu del Comedor",
|
||||
"aulario_id": "",
|
||||
"menus": [],
|
||||
"all_aularios": [],
|
||||
}
|
||||
)
|
||||
return render(request, "aulatek/comedor.html", context)
|
||||
|
||||
all_aularios = list(Aulario.objects.filter(org_id=org_id).order_by("name", "aulario_id"))
|
||||
selectable_shared = [row for row in all_aularios if row.id != aulario_row.id]
|
||||
selected_date = _parse_menu_date(request.GET.get("date", "")) or date.today()
|
||||
selected_menu_type = (request.GET.get("menu") or request.GET.get("type") or "").strip()
|
||||
|
||||
def comedor_url(target_date: date, target_type: str) -> str:
|
||||
return "/aulatek/comedor/?" + urlencode(
|
||||
{
|
||||
"aulario": aulario_row.aulario_id,
|
||||
"date": target_date.isoformat(),
|
||||
"menu": target_type,
|
||||
}
|
||||
)
|
||||
|
||||
default_types = [
|
||||
{"id": "basal", "label": "Basal", "color": "#0d6efd"},
|
||||
{"id": "vegetariano", "label": "Vegetariano", "color": "#198754"},
|
||||
{"id": "alergias", "label": "Alergias", "color": "#dc3545"},
|
||||
]
|
||||
|
||||
# Asegura tipos base para el aulario actual como origen.
|
||||
for item in default_types:
|
||||
ComedorMenuType.objects.get_or_create(
|
||||
org_id=org_id,
|
||||
source_aulario_id=aulario_row.aulario_id,
|
||||
type_id=item["id"],
|
||||
defaults={
|
||||
"label": item["label"],
|
||||
"color": item["color"],
|
||||
"created_by": user.username if user else "",
|
||||
},
|
||||
)
|
||||
|
||||
type_rows = list(
|
||||
ComedorMenuType.objects.filter(org_id=org_id)
|
||||
.filter(Q(source_aulario_id=aulario_row.aulario_id) | Q(shared_aularios=aulario_row))
|
||||
.distinct()
|
||||
.order_by("label")
|
||||
)
|
||||
|
||||
if not selected_menu_type and type_rows:
|
||||
selected_menu_type = type_rows[0].type_id
|
||||
|
||||
selected_type_row = next((row for row in type_rows if row.type_id == selected_menu_type), None)
|
||||
if not selected_type_row and type_rows:
|
||||
selected_type_row = type_rows[0]
|
||||
selected_menu_type = selected_type_row.type_id
|
||||
|
||||
source_aulario_id = selected_type_row.source_aulario_id if selected_type_row else aulario_row.aulario_id
|
||||
can_edit_selected_type = bool(selected_type_row and selected_type_row.source_aulario_id == aulario_row.aulario_id)
|
||||
|
||||
if request.method == "POST":
|
||||
action = (request.POST.get("action") or "").strip()
|
||||
|
||||
if action == "add_type":
|
||||
new_id = re.sub(r"[^a-z0-9_-]", "", (request.POST.get("new_type_id") or "").strip().lower())
|
||||
new_label = (request.POST.get("new_type_label") or "").strip()
|
||||
new_color = (request.POST.get("new_type_color") or "#0d6efd").strip() or "#0d6efd"
|
||||
if new_id and new_label:
|
||||
exists = ComedorMenuType.objects.filter(org_id=org_id, source_aulario_id=aulario_row.aulario_id, type_id=new_id).exists()
|
||||
if exists:
|
||||
messages.error(request, "Ese tipo ya existe.")
|
||||
else:
|
||||
ComedorMenuType.objects.create(
|
||||
org_id=org_id,
|
||||
source_aulario_id=aulario_row.aulario_id,
|
||||
type_id=new_id,
|
||||
label=new_label,
|
||||
color=new_color,
|
||||
created_by=user.username if user else "",
|
||||
)
|
||||
messages.success(request, "Tipo de menu creado.")
|
||||
return redirect(comedor_url(selected_date, new_id))
|
||||
|
||||
if action == "rename_type":
|
||||
rename_id = (request.POST.get("rename_type_id") or "").strip()
|
||||
row = ComedorMenuType.objects.filter(org_id=org_id, source_aulario_id=aulario_row.aulario_id, type_id=rename_id).first()
|
||||
if row:
|
||||
new_label = (request.POST.get("rename_type_label") or "").strip()
|
||||
new_color = (request.POST.get("rename_type_color") or "").strip()
|
||||
if new_label:
|
||||
row.label = new_label
|
||||
if new_color:
|
||||
row.color = new_color
|
||||
row.save()
|
||||
messages.success(request, "Tipo actualizado.")
|
||||
return redirect(comedor_url(selected_date, row.type_id))
|
||||
|
||||
if action == "delete_type":
|
||||
delete_id = (request.POST.get("delete_type_id") or "").strip()
|
||||
row = ComedorMenuType.objects.filter(org_id=org_id, source_aulario_id=aulario_row.aulario_id, type_id=delete_id).first()
|
||||
if row:
|
||||
ComedorMenu.objects.filter(org_id=org_id, aulario_id=aulario_row.aulario_id, menu_type=delete_id).delete()
|
||||
row.delete()
|
||||
messages.success(request, "Tipo eliminado.")
|
||||
return redirect(comedor_url(selected_date, ""))
|
||||
|
||||
if action == "share_type":
|
||||
if not selected_type_row or not can_edit_selected_type:
|
||||
return HttpResponseForbidden("No tienes permiso para compartir este tipo.")
|
||||
selected_ids = request.POST.getlist("shared_aularios")
|
||||
rows = Aulario.objects.filter(org_id=org_id, id__in=selected_ids).exclude(id=aulario_row.id)
|
||||
selected_type_row.shared_aularios.set(rows)
|
||||
messages.success(request, "Comparticion de tipo actualizada.")
|
||||
return redirect(comedor_url(selected_date, selected_type_row.type_id))
|
||||
|
||||
if action in {"save", "create", "update"}:
|
||||
if not selected_type_row:
|
||||
messages.error(request, "Selecciona un tipo valido.")
|
||||
return redirect(comedor_url(selected_date, ""))
|
||||
if not can_edit_selected_type:
|
||||
return HttpResponseForbidden("No puedes editar un tipo compartido desde otro aulario.")
|
||||
|
||||
menu_date = _parse_menu_date(request.POST.get("menu_date", "")) or selected_date
|
||||
first_name = (request.POST.get("first_name") or "").strip()
|
||||
second_name = (request.POST.get("second_name") or "").strip()
|
||||
dessert_name = (request.POST.get("dessert_name") or "").strip()
|
||||
if not first_name or not second_name or not dessert_name:
|
||||
messages.error(request, "Debes completar primero, segundo y postre.")
|
||||
return redirect(comedor_url(menu_date, selected_type_row.type_id))
|
||||
|
||||
menu = ComedorMenu.objects.filter(
|
||||
org_id=org_id,
|
||||
aulario_id=selected_type_row.source_aulario_id,
|
||||
menu_type=selected_type_row.type_id,
|
||||
menu_date=menu_date,
|
||||
).first()
|
||||
if not menu:
|
||||
menu = ComedorMenu(
|
||||
org_id=org_id,
|
||||
aulario_id=selected_type_row.source_aulario_id,
|
||||
menu_name=selected_type_row.label,
|
||||
menu_type=selected_type_row.type_id,
|
||||
menu_date=menu_date,
|
||||
created_by=user.username if user else "",
|
||||
)
|
||||
|
||||
menu.first_name = first_name
|
||||
menu.second_name = second_name
|
||||
menu.dessert_name = dessert_name
|
||||
|
||||
first_photo = request.FILES.get(f"first_photo_{menu.id}") if menu.id else request.FILES.get("first_photo")
|
||||
second_photo = request.FILES.get(f"second_photo_{menu.id}") if menu.id else request.FILES.get("second_photo")
|
||||
dessert_photo = request.FILES.get(f"dessert_photo_{menu.id}") if menu.id else request.FILES.get("dessert_photo")
|
||||
if first_photo and _valid_image(first_photo):
|
||||
if menu.first_photo:
|
||||
menu.first_photo.delete(save=False)
|
||||
menu.first_photo = first_photo
|
||||
if second_photo and _valid_image(second_photo):
|
||||
if menu.second_photo:
|
||||
menu.second_photo.delete(save=False)
|
||||
menu.second_photo = second_photo
|
||||
if dessert_photo and _valid_image(dessert_photo):
|
||||
if menu.dessert_photo:
|
||||
menu.dessert_photo.delete(save=False)
|
||||
menu.dessert_photo = dessert_photo
|
||||
|
||||
menu.save()
|
||||
messages.success(request, "Menu guardado correctamente.")
|
||||
return redirect(comedor_url(menu_date, selected_type_row.type_id))
|
||||
|
||||
if action == "delete":
|
||||
if not selected_type_row or not can_edit_selected_type:
|
||||
return HttpResponseForbidden("No puedes eliminar menu de un tipo compartido.")
|
||||
menu_id = int(request.POST.get("menu_id") or 0)
|
||||
menu = ComedorMenu.objects.filter(
|
||||
id=menu_id,
|
||||
org_id=org_id,
|
||||
aulario_id=selected_type_row.source_aulario_id,
|
||||
menu_type=selected_type_row.type_id,
|
||||
menu_date=selected_date,
|
||||
).first()
|
||||
if menu:
|
||||
if menu.first_photo:
|
||||
menu.first_photo.delete(save=False)
|
||||
if menu.second_photo:
|
||||
menu.second_photo.delete(save=False)
|
||||
if menu.dessert_photo:
|
||||
menu.dessert_photo.delete(save=False)
|
||||
menu.delete()
|
||||
messages.success(request, "Menu eliminado correctamente.")
|
||||
return redirect(comedor_url(selected_date, selected_type_row.type_id))
|
||||
|
||||
# Refresca tras posibles acciones.
|
||||
type_rows = list(
|
||||
ComedorMenuType.objects.filter(org_id=org_id)
|
||||
.filter(Q(source_aulario_id=aulario_row.aulario_id) | Q(shared_aularios=aulario_row))
|
||||
.distinct()
|
||||
.order_by("label")
|
||||
)
|
||||
if not selected_menu_type and type_rows:
|
||||
selected_menu_type = type_rows[0].type_id
|
||||
selected_type_row = next((row for row in type_rows if row.type_id == selected_menu_type), None)
|
||||
if not selected_type_row and type_rows:
|
||||
selected_type_row = type_rows[0]
|
||||
selected_menu_type = selected_type_row.type_id
|
||||
|
||||
source_aulario_id = selected_type_row.source_aulario_id if selected_type_row else aulario_row.aulario_id
|
||||
can_edit_selected_type = bool(selected_type_row and selected_type_row.source_aulario_id == aulario_row.aulario_id)
|
||||
|
||||
aularios_by_pk = {row.id: row for row in all_aularios}
|
||||
selected_menu = None
|
||||
if selected_type_row:
|
||||
selected_menu = ComedorMenu.objects.filter(
|
||||
org_id=org_id,
|
||||
aulario_id=source_aulario_id,
|
||||
menu_type=selected_type_row.type_id,
|
||||
menu_date=selected_date,
|
||||
).first()
|
||||
|
||||
selected_menu_data = None
|
||||
shared_ids = []
|
||||
if selected_type_row:
|
||||
shared_ids = list(selected_type_row.shared_aularios.values_list("id", flat=True))
|
||||
if selected_menu:
|
||||
owner_row = Aulario.objects.filter(org_id=org_id, aulario_id=source_aulario_id).first()
|
||||
selected_menu_data = {
|
||||
"obj": selected_menu,
|
||||
"is_owner": can_edit_selected_type,
|
||||
"origin_label": (owner_row.name or owner_row.aulario_id) if owner_row else source_aulario_id,
|
||||
"shared_ids": shared_ids,
|
||||
"shared_labels": [
|
||||
(aularios_by_pk[pk].name or aularios_by_pk[pk].aulario_id)
|
||||
for pk in shared_ids
|
||||
if pk in aularios_by_pk
|
||||
],
|
||||
}
|
||||
|
||||
menu_types = [
|
||||
{
|
||||
"id": row.type_id,
|
||||
"label": row.label,
|
||||
"color": row.color or "#0d6efd",
|
||||
"active": row.type_id == selected_menu_type,
|
||||
"is_owner": row.source_aulario_id == aulario_row.aulario_id,
|
||||
"source_aulario_id": row.source_aulario_id,
|
||||
}
|
||||
for row in type_rows
|
||||
]
|
||||
prev_date = selected_date.fromordinal(selected_date.toordinal() - 1)
|
||||
next_date = selected_date.fromordinal(selected_date.toordinal() + 1)
|
||||
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Menu del Comedor",
|
||||
"aulario_id": aulario_row.aulario_id,
|
||||
"aulario_name": aulario_row.name or aulario_row.aulario_id,
|
||||
"menu_types": menu_types,
|
||||
"selected_menu_type": selected_menu_type,
|
||||
"selected_date": selected_date,
|
||||
"prev_date": prev_date,
|
||||
"next_date": next_date,
|
||||
"selected_menu": selected_menu_data,
|
||||
"selected_type": selected_type_row,
|
||||
"can_edit_selected_type": can_edit_selected_type,
|
||||
"all_aularios": selectable_shared,
|
||||
"aulario_options": all_aularios,
|
||||
}
|
||||
)
|
||||
return render(request, "aulatek/comedor.html", context)
|
||||
|
||||
|
||||
def proyectos(request: HttpRequest):
|
||||
return _placeholder(request, "Proyectos")
|
||||
|
||||
|
||||
def supercafe(request: HttpRequest):
|
||||
return _placeholder(request, "SuperCafe")
|
||||
1
django_app/axia4_django/__init__.py
Normal file
1
django_app/axia4_django/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
django_app/axia4_django/asgi.py
Normal file
8
django_app/axia4_django/asgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "axia4_django.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
103
django_app/axia4_django/settings.py
Normal file
103
django_app/axia4_django/settings.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = BASE_DIR.parent
|
||||
DATA_ROOT = Path(os.environ.get("AXIA4_DATA_ROOT", REPO_ROOT / "DATA"))
|
||||
|
||||
|
||||
def resolve_db_path() -> Path:
|
||||
candidates = []
|
||||
env_path = os.environ.get("AXIA4_DB_PATH")
|
||||
if env_path:
|
||||
candidates.append(Path(env_path))
|
||||
candidates.extend([
|
||||
Path("/DATA/axia4.sqlite"),
|
||||
DATA_ROOT / "axia4.sqlite",
|
||||
])
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return candidates[-1]
|
||||
|
||||
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "axia4-dev-secret-key-change-me")
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
|
||||
ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"core",
|
||||
"account",
|
||||
"aulatek",
|
||||
"club",
|
||||
"sysadmin",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"core.middleware.AxiaAuthMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "axia4_django.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"core.context_processors.axia4",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "axia4_django.wsgi.application"
|
||||
ASGI_APPLICATION = "axia4_django.asgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": resolve_db_path(),
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
|
||||
LANGUAGE_CODE = "es-es"
|
||||
TIME_ZONE = "Europe/Madrid"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = [REPO_ROOT / "public_html" / "static"]
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = DATA_ROOT / "attachments"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
CSRF_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7
|
||||
|
||||
AXIA4_DATA_ROOT = DATA_ROOT
|
||||
AXIA4_AUTH_COOKIE = "auth_token"
|
||||
17
django_app/axia4_django/urls.py
Normal file
17
django_app/axia4_django/urls.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("django-admin/", admin.site.urls),
|
||||
path("", include("core.urls")),
|
||||
path("account/", include("account.urls")),
|
||||
path("aulatek/", include("aulatek.urls")),
|
||||
path("club/", include("club.urls")),
|
||||
path("sysadmin/", include("sysadmin.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
8
django_app/axia4_django/wsgi.py
Normal file
8
django_app/axia4_django/wsgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "axia4_django.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
django_app/club/__init__.py
Normal file
0
django_app/club/__init__.py
Normal file
6
django_app/club/apps.py
Normal file
6
django_app/club/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ClubConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "club"
|
||||
13
django_app/club/urls.py
Normal file
13
django_app/club/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "club"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.club_home, name="club_home"),
|
||||
path("cal/", views.club_event, name="club_event"),
|
||||
path("foto/", views.club_file, name="club_file"),
|
||||
path("upload/", views.club_upload, name="club_upload"),
|
||||
]
|
||||
74
django_app/club/views.py
Normal file
74
django_app/club/views.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404, HttpRequest
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
from core.models import ClubEvent
|
||||
from core.shell import shell_context
|
||||
|
||||
|
||||
def club_home(request: HttpRequest):
|
||||
image_root = settings.AXIA4_DATA_ROOT / "club" / "IMG"
|
||||
events = []
|
||||
if image_root.exists():
|
||||
for folder in sorted([path for path in image_root.iterdir() if path.is_dir()], reverse=True):
|
||||
event = ClubEvent.objects.filter(date_ref=folder.name).first()
|
||||
payload = event.payload if event else {}
|
||||
date_label = "/".join(reversed(folder.name.split("-")))
|
||||
events.append({"ref": folder.name, "date": date_label, "title": payload.get("title") or "Por nombrar"})
|
||||
|
||||
context = shell_context(request, "club", "Club - Inicio")
|
||||
context["events"] = events
|
||||
return render(request, "core/club_index.html", context)
|
||||
|
||||
|
||||
def club_event(request: HttpRequest):
|
||||
ref = (request.GET.get("f") or "").strip()
|
||||
event = ClubEvent.objects.filter(date_ref=ref).first()
|
||||
payload = event.payload if event else {}
|
||||
date_label = "/".join(reversed(ref.split("-"))) if ref else ""
|
||||
|
||||
image_root = settings.AXIA4_DATA_ROOT / "club" / "IMG" / ref
|
||||
photos = []
|
||||
if image_root.exists():
|
||||
for person_dir in sorted([path for path in image_root.iterdir() if path.is_dir()]):
|
||||
for photo in sorted(person_dir.iterdir()):
|
||||
if photo.is_file() and photo.suffix.lower() != ".thumbnail":
|
||||
rel_path = f"{ref}/{person_dir.name}/{photo.name}"
|
||||
photos.append(
|
||||
{
|
||||
"author": person_dir.name,
|
||||
"name": photo.name,
|
||||
"download": f"/club/foto/?f={rel_path}",
|
||||
}
|
||||
)
|
||||
|
||||
context = shell_context(request, "club", f"{date_label} - Club" if date_label else "Club")
|
||||
context.update({"event_ref": ref, "event_date": date_label, "event_data": payload, "photos": photos})
|
||||
return render(request, "core/club_event.html", context)
|
||||
|
||||
|
||||
@require_GET
|
||||
def club_file(request: HttpRequest):
|
||||
rel_path = (request.GET.get("f") or "").strip().replace("..", "")
|
||||
base = (settings.AXIA4_DATA_ROOT / "club" / "IMG").resolve()
|
||||
candidate = (base / rel_path).resolve()
|
||||
if not str(candidate).startswith(str(base)) or not candidate.exists() or not candidate.is_file():
|
||||
raise Http404("Archivo no encontrado")
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(candidate.name)
|
||||
return FileResponse(candidate.open("rb"), content_type=mime_type or "application/octet-stream")
|
||||
|
||||
|
||||
def club_upload(request: HttpRequest):
|
||||
context = shell_context(request, "club", "Subir fotos")
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Subir fotos",
|
||||
"module_description": "La subida de fotos se portara sobre esta misma base.",
|
||||
"tiles": [],
|
||||
}
|
||||
)
|
||||
return render(request, "core/module.html", context)
|
||||
1
django_app/core/__init__.py
Normal file
1
django_app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
78
django_app/core/admin.py
Normal file
78
django_app/core/admin.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .forms import UserAdminForm, UserOrgAdminForm
|
||||
from .models import Aulario, ClubEvent, Config, Invitation, Organization, User, UserOrg, UserSession, generate_short_uuid
|
||||
|
||||
|
||||
admin.site.site_header = "Axia4 Django Admin"
|
||||
admin.site.site_title = "Axia4 Admin"
|
||||
admin.site.index_title = "Panel interno de administracion"
|
||||
|
||||
|
||||
@admin.register(Config)
|
||||
class ConfigAdmin(admin.ModelAdmin):
|
||||
list_display = ("key", "value")
|
||||
search_fields = ("key", "value")
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
form = UserAdminForm
|
||||
list_display = ("username", "display_name", "email", "google_auth", "created_at")
|
||||
search_fields = ("username", "display_name", "email")
|
||||
list_filter = ("google_auth",)
|
||||
|
||||
|
||||
@admin.register(Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
list_display = ("org_id", "org_name", "created_at")
|
||||
search_fields = ("org_id", "org_name")
|
||||
|
||||
def get_changeform_initial_data(self, request):
|
||||
initial = super().get_changeform_initial_data(request)
|
||||
initial.setdefault("org_id", generate_short_uuid())
|
||||
return initial
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not obj.org_id:
|
||||
obj.org_id = generate_short_uuid()
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(UserOrg)
|
||||
class UserOrgAdmin(admin.ModelAdmin):
|
||||
form = UserOrgAdminForm
|
||||
list_display = ("user", "org", "permissions_badge")
|
||||
search_fields = ("user__username", "org__org_id", "role")
|
||||
autocomplete_fields = ("user", "org")
|
||||
|
||||
@admin.display(description="Permisos")
|
||||
def permissions_badge(self, obj):
|
||||
return obj.permissions_display or "Sin permisos"
|
||||
|
||||
|
||||
@admin.register(Aulario)
|
||||
class AularioAdmin(admin.ModelAdmin):
|
||||
list_display = ("aulario_id", "name", "org")
|
||||
search_fields = ("aulario_id", "name", "org__org_id", "org__org_name")
|
||||
autocomplete_fields = ("org",)
|
||||
|
||||
|
||||
@admin.register(UserSession)
|
||||
class UserSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ("username", "ip_address", "created_at", "last_active")
|
||||
search_fields = ("username", "ip_address", "user_agent")
|
||||
readonly_fields = ("session_token", "remember_token_hash")
|
||||
|
||||
|
||||
@admin.register(ClubEvent)
|
||||
class ClubEventAdmin(admin.ModelAdmin):
|
||||
list_display = ("date_ref",)
|
||||
search_fields = ("date_ref", "data")
|
||||
|
||||
|
||||
@admin.register(Invitation)
|
||||
class InvitationAdmin(admin.ModelAdmin):
|
||||
list_display = ("code", "active", "single_use", "created_at")
|
||||
list_filter = ("active", "single_use")
|
||||
search_fields = ("code",)
|
||||
6
django_app/core/apps.py
Normal file
6
django_app/core/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
302
django_app/core/auth.py
Normal file
302
django_app/core/auth.py
Normal file
@@ -0,0 +1,302 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password, identify_hasher, make_password
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Organization, User, UserOrg, UserSession
|
||||
|
||||
|
||||
SESSION_USER_KEY = "axia4.user"
|
||||
SESSION_ACTIVE_ORG_KEY = "axia4.active_org"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AxiaUser:
|
||||
record: User
|
||||
active_org: str
|
||||
active_org_name: str
|
||||
organizations: list[dict]
|
||||
permissions: list[str]
|
||||
org_permissions: list[str]
|
||||
permissions_display: str
|
||||
aulas: list[str]
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
return self.record.username
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.record.display_name or self.record.username
|
||||
|
||||
@property
|
||||
def email(self) -> str:
|
||||
return self.record.email
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
parts = [part for part in self.display_name.split() if part]
|
||||
first = parts[0][0] if parts else "?"
|
||||
second = parts[1][0] if len(parts) > 1 else ""
|
||||
return (first + second).upper()
|
||||
|
||||
@property
|
||||
def google_auth(self) -> bool:
|
||||
return bool(self.record.google_auth)
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
return permission in self.permissions
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
# Alias legacy para plantillas/codigo existente.
|
||||
return self.permissions_display
|
||||
|
||||
|
||||
def _session_token(request: HttpRequest) -> Optional[str]:
|
||||
session_key = request.session.session_key
|
||||
if not session_key:
|
||||
return None
|
||||
return hashlib.sha256(session_key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _remember_hash(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _normalize_password_hash(password_hash: str) -> bytes:
|
||||
normalized = password_hash or ""
|
||||
if normalized.startswith("$2y$") or normalized.startswith("$2a$"):
|
||||
normalized = "$2b$" + normalized[4:]
|
||||
return normalized.encode("utf-8")
|
||||
|
||||
|
||||
def _is_django_password_hash(password_hash: str) -> bool:
|
||||
try:
|
||||
identify_hasher(password_hash)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_user(identifier: str) -> Optional[User]:
|
||||
identifier = (identifier or "").strip().lower()
|
||||
if not identifier:
|
||||
return None
|
||||
if "@" in identifier:
|
||||
return User.objects.filter(email__iexact=identifier).first()
|
||||
return User.objects.filter(username__iexact=identifier).first()
|
||||
|
||||
|
||||
def verify_password(user: User, raw_password: str) -> bool:
|
||||
if not user or not user.password_hash:
|
||||
return False
|
||||
if _is_django_password_hash(user.password_hash):
|
||||
return check_password(raw_password, user.password_hash)
|
||||
try:
|
||||
valid = bcrypt.checkpw(raw_password.encode("utf-8"), _normalize_password_hash(user.password_hash))
|
||||
except ValueError:
|
||||
return False
|
||||
if valid:
|
||||
user.password_hash = make_password(raw_password)
|
||||
user.save(update_fields=["password_hash"])
|
||||
return valid
|
||||
|
||||
|
||||
def authenticate(identifier: str, raw_password: str) -> Optional[User]:
|
||||
user = get_user(identifier)
|
||||
if user and verify_password(user, raw_password):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def _organization_links(user: User) -> list[UserOrg]:
|
||||
return list(
|
||||
UserOrg.objects.filter(user=user)
|
||||
.select_related("org")
|
||||
.order_by("org__org_id")
|
||||
)
|
||||
|
||||
|
||||
def build_axia_user(user: User, request: Optional[HttpRequest] = None) -> AxiaUser:
|
||||
links = _organization_links(user)
|
||||
global_permissions = [str(value) for value in user.permissions_data if isinstance(value, str)]
|
||||
organizations = [
|
||||
{
|
||||
"id": link.org.org_id,
|
||||
"name": link.org.org_name or link.org.org_id,
|
||||
"permissions": link.permissions_display,
|
||||
"permissions_values": link.permissions_values,
|
||||
"aulas": link.aulas,
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
session_active = request.session.get(SESSION_ACTIVE_ORG_KEY) if request else None
|
||||
org_ids = [org["id"] for org in organizations]
|
||||
active_org = session_active if session_active in org_ids else (org_ids[0] if org_ids else "")
|
||||
active_link = next((org for org in organizations if org["id"] == active_org), None)
|
||||
active_org_permissions = [str(value) for value in (active_link.get("permissions_values", []) if active_link else []) if isinstance(value, str)]
|
||||
merged_permissions = []
|
||||
seen = set()
|
||||
for value in global_permissions + active_org_permissions:
|
||||
if value and value not in seen:
|
||||
merged_permissions.append(value)
|
||||
seen.add(value)
|
||||
return AxiaUser(
|
||||
record=user,
|
||||
active_org=active_org,
|
||||
active_org_name=active_link["name"] if active_link else active_org,
|
||||
organizations=organizations,
|
||||
permissions=merged_permissions,
|
||||
org_permissions=active_org_permissions,
|
||||
permissions_display=active_link["permissions"] if active_link else "",
|
||||
aulas=active_link["aulas"] if active_link else [],
|
||||
)
|
||||
|
||||
|
||||
def _touch_session(token: Optional[str]) -> None:
|
||||
if token:
|
||||
UserSession.objects.filter(session_token=token).update(last_active=timezone.now())
|
||||
|
||||
|
||||
def load_user_from_session(request: HttpRequest) -> Optional[AxiaUser]:
|
||||
username = request.session.get(SESSION_USER_KEY)
|
||||
if not username:
|
||||
return None
|
||||
token = _session_token(request)
|
||||
if not token:
|
||||
return None
|
||||
valid = UserSession.objects.filter(session_token=token, username__iexact=username).exists()
|
||||
if not valid:
|
||||
request.session.flush()
|
||||
request._axia_clear_auth_cookie = True
|
||||
return None
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
request.session.flush()
|
||||
request._axia_clear_auth_cookie = True
|
||||
return None
|
||||
_touch_session(token)
|
||||
return build_axia_user(user, request)
|
||||
|
||||
|
||||
def restore_user_from_cookie(request: HttpRequest) -> Optional[AxiaUser]:
|
||||
raw_token = request.COOKIES.get(settings.AXIA4_AUTH_COOKIE)
|
||||
if not raw_token:
|
||||
return None
|
||||
remember_hash = _remember_hash(raw_token)
|
||||
session_row = UserSession.objects.filter(remember_token_hash=remember_hash).first()
|
||||
if not session_row:
|
||||
request._axia_clear_auth_cookie = True
|
||||
return None
|
||||
user = get_user(session_row.username)
|
||||
if not user:
|
||||
request._axia_clear_auth_cookie = True
|
||||
return None
|
||||
request.session.cycle_key()
|
||||
request.session[SESSION_USER_KEY] = user.username
|
||||
request.session.setdefault(SESSION_ACTIVE_ORG_KEY, request.session.get(SESSION_ACTIVE_ORG_KEY, ""))
|
||||
request.session.save()
|
||||
new_token = _session_token(request)
|
||||
if new_token:
|
||||
session_row.session_token = new_token
|
||||
session_row.last_active = timezone.now()
|
||||
session_row.save(update_fields=["session_token", "last_active"])
|
||||
return build_axia_user(user, request)
|
||||
|
||||
|
||||
def login_user(request: HttpRequest, user: User) -> AxiaUser:
|
||||
request.session.flush()
|
||||
request.session.cycle_key()
|
||||
request.session[SESSION_USER_KEY] = user.username
|
||||
axia_user = build_axia_user(user, request)
|
||||
request.session[SESSION_ACTIVE_ORG_KEY] = axia_user.active_org
|
||||
request.session.save()
|
||||
token = _session_token(request)
|
||||
raw_remember = secrets.token_hex(32)
|
||||
remember_hash = _remember_hash(raw_remember)
|
||||
if token:
|
||||
ip_address = (request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR") or "").split(",")[0].strip()
|
||||
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:512]
|
||||
UserSession.objects.update_or_create(
|
||||
session_token=token,
|
||||
defaults={
|
||||
"username": user.username.lower(),
|
||||
"ip_address": ip_address,
|
||||
"user_agent": user_agent,
|
||||
"remember_token_hash": remember_hash,
|
||||
},
|
||||
)
|
||||
request._axia_auth_cookie_value = raw_remember
|
||||
return build_axia_user(user, request)
|
||||
|
||||
|
||||
def logout_user(request: HttpRequest) -> None:
|
||||
token = _session_token(request)
|
||||
if token:
|
||||
UserSession.objects.filter(session_token=token).delete()
|
||||
request.session.flush()
|
||||
request._axia_clear_auth_cookie = True
|
||||
|
||||
|
||||
def set_active_organization(request: HttpRequest, organization_id: str) -> None:
|
||||
request.session[SESSION_ACTIVE_ORG_KEY] = organization_id
|
||||
|
||||
|
||||
def parse_user_agent(user_agent: str) -> dict:
|
||||
ua = user_agent or ""
|
||||
operating_system = "Desconocido"
|
||||
if "Android" in ua:
|
||||
operating_system = "Android"
|
||||
elif "iPhone" in ua or "iPad" in ua:
|
||||
operating_system = "iOS"
|
||||
elif "Windows" in ua:
|
||||
operating_system = "Windows"
|
||||
elif "Macintosh" in ua or "Mac OS" in ua:
|
||||
operating_system = "macOS"
|
||||
elif "Linux" in ua:
|
||||
operating_system = "Linux"
|
||||
elif "CrOS" in ua:
|
||||
operating_system = "ChromeOS"
|
||||
|
||||
browser = "Desconocido"
|
||||
if ua.startswith("Axia4Auth/"):
|
||||
browser = "Axia4 App"
|
||||
elif "Edg/" in ua:
|
||||
browser = "Edge"
|
||||
elif "OPR/" in ua or "Opera" in ua:
|
||||
browser = "Opera"
|
||||
elif "Chrome" in ua:
|
||||
browser = "Chrome"
|
||||
elif "Firefox" in ua:
|
||||
browser = "Firefox"
|
||||
elif "Safari" in ua:
|
||||
browser = "Safari"
|
||||
|
||||
icons = {
|
||||
"Chrome": "🌐",
|
||||
"Firefox": "🦊",
|
||||
"Safari": "🧭",
|
||||
"Edge": "🔷",
|
||||
"Opera": "🔴",
|
||||
"Axia4 App": "📱",
|
||||
}
|
||||
return {
|
||||
"browser": browser,
|
||||
"os": operating_system,
|
||||
"icon": icons.get(browser, "💻"),
|
||||
}
|
||||
|
||||
|
||||
def list_user_sessions(username: str):
|
||||
return UserSession.objects.filter(username__iexact=username).order_by("-last_active")
|
||||
|
||||
|
||||
def organization_name_map() -> dict:
|
||||
return {org.org_id: (org.org_name or org.org_id) for org in Organization.objects.all()}
|
||||
21
django_app/core/context_processors.py
Normal file
21
django_app/core/context_processors.py
Normal file
@@ -0,0 +1,21 @@
|
||||
def axia4(request):
|
||||
user = getattr(request, "axia_user", None)
|
||||
from .models import Config
|
||||
from django.db import OperationalError
|
||||
|
||||
try:
|
||||
config = {item.key: item.value for item in Config.objects.all()} if Config.objects.exists() else {}
|
||||
except OperationalError:
|
||||
config = {}
|
||||
return {
|
||||
"axia_user": user,
|
||||
"auth_ok": user is not None,
|
||||
"auth_config": config,
|
||||
"global_apps": [
|
||||
{"href": "/", "icon": "logo.png", "label": "Axia4"},
|
||||
{"href": "/club/", "icon": "logo-club.png", "label": "Club"},
|
||||
{"href": "/aulatek/", "icon": "logo-entreaulas.png", "label": "AulaTek"},
|
||||
{"href": "/account/", "icon": "logo-account.png", "label": "Cuenta"},
|
||||
{"href": "/sysadmin/", "icon": "logo-sysadmin.png", "label": "SysAdmin"},
|
||||
],
|
||||
}
|
||||
36
django_app/core/db_bootstrap.py
Normal file
36
django_app/core/db_bootstrap.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import connection
|
||||
|
||||
|
||||
_SCHEMA_READY = False
|
||||
|
||||
|
||||
def ensure_axia4_schema() -> None:
|
||||
global _SCHEMA_READY
|
||||
|
||||
if _SCHEMA_READY:
|
||||
return
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='config'")
|
||||
exists = cursor.fetchone() is not None
|
||||
|
||||
if exists:
|
||||
_SCHEMA_READY = True
|
||||
return
|
||||
|
||||
migrations_dir = Path(__file__).resolve().parents[2] / "public_html" / "_incl" / "migrations"
|
||||
sql_files = [
|
||||
migrations_dir / "001_initial_schema.sql",
|
||||
migrations_dir / "003_organizaciones.sql",
|
||||
migrations_dir / "004_user_sessions.sql",
|
||||
migrations_dir / "005_remember_token.sql",
|
||||
]
|
||||
|
||||
connection.ensure_connection()
|
||||
raw_connection = connection.connection
|
||||
for file_path in sql_files:
|
||||
raw_connection.executescript(file_path.read_text(encoding="utf-8"))
|
||||
raw_connection.commit()
|
||||
_SCHEMA_READY = True
|
||||
270
django_app/core/forms.py
Normal file
270
django_app/core/forms.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import (
|
||||
PERMISSION_LABELS,
|
||||
Invitation,
|
||||
Organization,
|
||||
User,
|
||||
UserOrg,
|
||||
generate_short_uuid,
|
||||
parse_permission_values,
|
||||
serialize_permission_values,
|
||||
)
|
||||
|
||||
|
||||
BASE_PERMISSION_CHOICES = [
|
||||
("aulatek:alumno", PERMISSION_LABELS["aulatek:alumno"]),
|
||||
("aulatek:docente", PERMISSION_LABELS["aulatek:docente"]),
|
||||
("aulatek:admin", PERMISSION_LABELS["aulatek:admin"]),
|
||||
]
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
user = forms.CharField(
|
||||
label="Usuario o correo electronico",
|
||||
max_length=150,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "Ej: pepeflores o pepeflores@gmail.com",
|
||||
"autocomplete": "username",
|
||||
}
|
||||
),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label="Contrasena",
|
||||
widget=forms.PasswordInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "Ej: PerroPiano482",
|
||||
"autocomplete": "current-password",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DjangoAdminOnboardingForm(forms.Form):
|
||||
username = forms.CharField(
|
||||
label="Usuario admin",
|
||||
max_length=150,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "Ej: admin",
|
||||
"autocomplete": "username",
|
||||
}
|
||||
),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label="Correo electronico",
|
||||
required=False,
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "Ej: admin@dominio.com",
|
||||
"autocomplete": "email",
|
||||
}
|
||||
),
|
||||
)
|
||||
password1 = forms.CharField(
|
||||
label="Contrasena",
|
||||
min_length=8,
|
||||
widget=forms.PasswordInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"autocomplete": "new-password",
|
||||
}
|
||||
),
|
||||
)
|
||||
password2 = forms.CharField(
|
||||
label="Repite la contrasena",
|
||||
widget=forms.PasswordInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"autocomplete": "new-password",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def clean_username(self):
|
||||
username = (self.cleaned_data.get("username") or "").strip()
|
||||
UserModel = get_user_model()
|
||||
if UserModel.objects.filter(username=username).exists():
|
||||
raise forms.ValidationError("Ese usuario ya existe.")
|
||||
return username
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
password1 = cleaned_data.get("password1")
|
||||
password2 = cleaned_data.get("password2")
|
||||
if password1 and password2 and password1 != password2:
|
||||
self.add_error("password2", "Las contrasenas no coinciden.")
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class UserAdminForm(forms.ModelForm):
|
||||
password_hash = forms.CharField(
|
||||
label="Contrasena",
|
||||
required=False,
|
||||
widget=forms.PasswordInput(render_value=False, attrs={"autocomplete": "new-password"}),
|
||||
help_text="Deja este campo vacio para conservar la contrasena actual. Si escribes una nueva, se guardara con hash de Django.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = "__all__"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.instance.pk:
|
||||
self.fields["password_hash"].required = True
|
||||
self.fields["password_hash"].help_text = "Introduce la contrasena inicial. Se guardara con hash de Django."
|
||||
self.initial["password_hash"] = ""
|
||||
|
||||
def clean_password_hash(self):
|
||||
raw_password = self.cleaned_data.get("password_hash", "")
|
||||
if raw_password:
|
||||
return make_password(raw_password)
|
||||
if self.instance.pk:
|
||||
return self.instance.password_hash
|
||||
raise forms.ValidationError("La contrasena es obligatoria para usuarios nuevos.")
|
||||
|
||||
|
||||
class UserOrgAdminForm(forms.ModelForm):
|
||||
role = forms.MultipleChoiceField(
|
||||
label="Permisos",
|
||||
choices=BASE_PERMISSION_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserOrg
|
||||
fields = "__all__"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
current_values = parse_permission_values(self.instance.role if self.instance and self.instance.pk else self.initial.get("role", []))
|
||||
choice_map = dict(BASE_PERMISSION_CHOICES)
|
||||
for value in current_values:
|
||||
choice_map.setdefault(value, value.replace("_", " ").title())
|
||||
self.fields["role"].choices = list(choice_map.items())
|
||||
self.initial["role"] = current_values
|
||||
|
||||
def clean_role(self):
|
||||
return serialize_permission_values(self.cleaned_data.get("role", []))
|
||||
|
||||
|
||||
class SysadminUserForm(forms.ModelForm):
|
||||
raw_password = forms.CharField(
|
||||
label="Contrasena",
|
||||
required=False,
|
||||
widget=forms.PasswordInput(render_value=False, attrs={"autocomplete": "new-password", "class": "form-control"}),
|
||||
help_text="En edicion, dejalo vacio para conservar la contrasena actual.",
|
||||
)
|
||||
permissions = forms.CharField(
|
||||
label="Permisos",
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 4, "class": "form-control", "placeholder": "sysadmin:access, aulatek:docente"}),
|
||||
help_text="Separados por coma o salto de linea.",
|
||||
)
|
||||
meta = forms.CharField(
|
||||
label="Meta (JSON)",
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 3, "class": "form-control", "placeholder": "{}"}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["username", "display_name", "email", "raw_password", "permissions", "google_auth", "meta"]
|
||||
widgets = {
|
||||
"username": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"display_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control"}),
|
||||
"google_auth": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance and self.instance.pk:
|
||||
self.fields["permissions"].initial = "\n".join(self.instance.permissions_data)
|
||||
self.fields["meta"].initial = self.instance.meta or "{}"
|
||||
self.fields["raw_password"].required = False
|
||||
else:
|
||||
self.fields["raw_password"].required = True
|
||||
|
||||
def clean_permissions(self):
|
||||
raw = (self.cleaned_data.get("permissions") or "").replace("\n", ",")
|
||||
values = [value.strip() for value in raw.split(",") if value.strip()]
|
||||
unique = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if value not in seen:
|
||||
unique.append(value)
|
||||
seen.add(value)
|
||||
return serialize_permission_values(unique)
|
||||
|
||||
def clean_meta(self):
|
||||
raw = (self.cleaned_data.get("meta") or "{}").strip() or "{}"
|
||||
parsed = User.parse_json(raw, None)
|
||||
if not isinstance(parsed, dict):
|
||||
raise forms.ValidationError("Meta debe ser un JSON valido de tipo objeto.")
|
||||
return raw
|
||||
|
||||
def clean_raw_password(self):
|
||||
value = (self.cleaned_data.get("raw_password") or "").strip()
|
||||
if not self.instance.pk and not value:
|
||||
raise forms.ValidationError("La contrasena es obligatoria para usuarios nuevos.")
|
||||
return value
|
||||
|
||||
def save(self, commit=True):
|
||||
obj = super().save(commit=False)
|
||||
raw_password = self.cleaned_data.get("raw_password")
|
||||
if raw_password:
|
||||
obj.password_hash = make_password(raw_password)
|
||||
if not obj.pk and not obj.password_hash:
|
||||
obj.password_hash = make_password("changeme")
|
||||
now = timezone.now()
|
||||
if not obj.pk:
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
if commit:
|
||||
obj.save()
|
||||
return obj
|
||||
|
||||
|
||||
class SysadminOrganizationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = ["org_id", "org_name"]
|
||||
widgets = {
|
||||
"org_id": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"org_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.instance.pk and not self.initial.get("org_id"):
|
||||
self.initial["org_id"] = generate_short_uuid()
|
||||
|
||||
|
||||
class SysadminInvitationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Invitation
|
||||
fields = ["code", "active", "single_use"]
|
||||
widgets = {
|
||||
"code": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"single_use": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
def clean_code(self):
|
||||
value = (self.cleaned_data.get("code") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
import secrets
|
||||
|
||||
return secrets.token_urlsafe(8).replace("-", "").replace("_", "")[:10]
|
||||
33
django_app/core/middleware.py
Normal file
33
django_app/core/middleware.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class AxiaAuthMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
from .auth import load_user_from_session, restore_user_from_cookie
|
||||
from .db_bootstrap import ensure_axia4_schema
|
||||
|
||||
ensure_axia4_schema()
|
||||
|
||||
request.axia_user = load_user_from_session(request)
|
||||
if request.axia_user is None:
|
||||
request.axia_user = restore_user_from_cookie(request)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
cookie_name = settings.AXIA4_AUTH_COOKIE
|
||||
if getattr(request, "_axia_auth_cookie_value", None):
|
||||
response.set_cookie(
|
||||
cookie_name,
|
||||
request._axia_auth_cookie_value,
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
httponly=True,
|
||||
secure=not settings.DEBUG,
|
||||
samesite="Lax",
|
||||
path="/",
|
||||
)
|
||||
if getattr(request, "_axia_clear_auth_cookie", False):
|
||||
response.delete_cookie(cookie_name, path="/", samesite="Lax")
|
||||
return response
|
||||
178
django_app/core/migrations/0001_initial.py
Normal file
178
django_app/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-16 11:19
|
||||
|
||||
import core.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ClubEvent",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("date_ref", models.TextField(unique=True)),
|
||||
("data", models.TextField(default="{}")),
|
||||
],
|
||||
options={
|
||||
"db_table": "club_events",
|
||||
"managed": True,
|
||||
},
|
||||
bases=(models.Model, core.models.JsonTextMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Config",
|
||||
fields=[
|
||||
("key", models.TextField(primary_key=True, serialize=False)),
|
||||
("value", models.TextField(default="")),
|
||||
],
|
||||
options={
|
||||
"db_table": "config",
|
||||
"managed": True,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Invitation",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("code", models.TextField(unique=True)),
|
||||
("active", models.IntegerField(default=1)),
|
||||
("single_use", models.IntegerField(default=1)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "invitations",
|
||||
"managed": True,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Organization",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"org_id",
|
||||
models.TextField(
|
||||
blank=True, default=core.models.generate_short_uuid, unique=True
|
||||
),
|
||||
),
|
||||
("org_name", models.TextField(default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "organizaciones",
|
||||
"managed": True,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("username", models.TextField(unique=True)),
|
||||
("display_name", models.TextField(default="")),
|
||||
("email", models.TextField(default="")),
|
||||
("password_hash", models.TextField(default="")),
|
||||
("permissions", models.TextField(default="[]")),
|
||||
("google_auth", models.IntegerField(default=0)),
|
||||
("meta", models.TextField(default="{}")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "users",
|
||||
"managed": True,
|
||||
},
|
||||
bases=(models.Model, core.models.JsonTextMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserSession",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("session_token", models.TextField(unique=True)),
|
||||
("username", models.TextField()),
|
||||
("ip_address", models.TextField(default="")),
|
||||
("user_agent", models.TextField(default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("last_active", models.DateTimeField(auto_now=True)),
|
||||
("remember_token_hash", models.TextField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_sessions",
|
||||
"managed": True,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Aulario",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"aulario_id",
|
||||
models.TextField(default=core.models.generate_short_uuid),
|
||||
),
|
||||
("name", models.TextField(default="")),
|
||||
(
|
||||
"icon_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=core.models.aulario_icon_upload_to,
|
||||
),
|
||||
),
|
||||
("extra", models.TextField(default="{}")),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
db_column="org_id",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="core.organization",
|
||||
to_field="org_id",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "aularios",
|
||||
"managed": True,
|
||||
},
|
||||
bases=(models.Model, core.models.JsonTextMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserOrg",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
db_column="rowid", primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("role", models.TextField(default="")),
|
||||
("ea_aulas", models.TextField(default="[]")),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
db_column="org_id",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="core.organization",
|
||||
to_field="org_id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_column="user_id",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="core.user",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_orgs",
|
||||
"managed": True,
|
||||
"unique_together": {("user", "org")},
|
||||
},
|
||||
bases=(models.Model, core.models.JsonTextMixin),
|
||||
),
|
||||
]
|
||||
1
django_app/core/migrations/__init__.py
Normal file
1
django_app/core/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
284
django_app/core/models.py
Normal file
284
django_app/core/models.py
Normal file
@@ -0,0 +1,284 @@
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
PERMISSION_LABELS = {
|
||||
"aulatek:alumno": "AulaTek: Alumno",
|
||||
"aulatek:docente": "AulaTek: Docente",
|
||||
"aulatek:admin": "AulaTek: Admin",
|
||||
}
|
||||
|
||||
# Alias legacy para compatibilidad hacia atras.
|
||||
ROLE_LABELS = PERMISSION_LABELS
|
||||
|
||||
|
||||
def aulario_icon_upload_to(instance, filename: str) -> str:
|
||||
ext = Path(filename or "").suffix.lower() or ".bin"
|
||||
org_id = getattr(instance.org, "org_id", None) or "org"
|
||||
aulario_id = getattr(instance, "aulario_id", None) or "aulario"
|
||||
org_id = re.sub(r"[^A-Za-z0-9._-]", "", str(org_id))
|
||||
aulario_id = re.sub(r"[^A-Za-z0-9._-]", "", str(aulario_id))
|
||||
return f"aularios/{org_id}/{aulario_id}/icon{ext}"
|
||||
|
||||
|
||||
class JsonTextMixin:
|
||||
@staticmethod
|
||||
def parse_json(raw, default):
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed is not None else default
|
||||
|
||||
|
||||
def generate_short_uuid() -> str:
|
||||
while True:
|
||||
candidate = uuid.uuid4().hex[:12]
|
||||
if not Organization.objects.filter(org_id=candidate).exists():
|
||||
return candidate
|
||||
|
||||
|
||||
def parse_permission_values(raw) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
values = [str(value).strip() for value in raw if str(value).strip()]
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (TypeError, ValueError):
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
values = [str(value).strip() for value in parsed if str(value).strip()]
|
||||
elif "," in text:
|
||||
values = [part.strip() for part in text.split(",") if part.strip()]
|
||||
else:
|
||||
values = [text]
|
||||
|
||||
unique_values = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if value not in seen:
|
||||
unique_values.append(value)
|
||||
seen.add(value)
|
||||
return unique_values
|
||||
|
||||
|
||||
def serialize_permission_values(values) -> str:
|
||||
return json.dumps(parse_permission_values(values), ensure_ascii=True)
|
||||
|
||||
|
||||
def humanize_permission(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return PERMISSION_LABELS.get(text.lower(), text.replace("_", " ").replace("-", " ").title())
|
||||
|
||||
|
||||
# Alias legacy para compatibilidad hacia atras.
|
||||
parse_role_values = parse_permission_values
|
||||
serialize_role_values = serialize_permission_values
|
||||
humanize_role = humanize_permission
|
||||
|
||||
|
||||
class Config(models.Model):
|
||||
key = models.TextField(primary_key=True)
|
||||
value = models.TextField(default="")
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
managed = True
|
||||
db_table = "config"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.key} = {self.value}"
|
||||
|
||||
|
||||
class User(models.Model, JsonTextMixin):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
username = models.TextField(unique=True)
|
||||
display_name = models.TextField(default="")
|
||||
email = models.TextField(default="")
|
||||
password_hash = models.TextField(default="")
|
||||
permissions = models.TextField(default="[]")
|
||||
google_auth = models.IntegerField(default=0)
|
||||
meta = models.TextField(default="{}")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
managed = True
|
||||
db_table = "users"
|
||||
|
||||
@property
|
||||
def permissions_data(self):
|
||||
data = self.parse_json(self.permissions, [])
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
@property
|
||||
def meta_data(self):
|
||||
data = self.parse_json(self.meta, {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def __str__(self):
|
||||
label = self.display_name or self.username
|
||||
extras = [value for value in [self.username, self.email] if value]
|
||||
if extras:
|
||||
return f"{label} ({' | '.join(extras)})"
|
||||
return label
|
||||
|
||||
|
||||
class Organization(models.Model):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
org_id = models.TextField(unique=True, blank=True, default=generate_short_uuid)
|
||||
org_name = models.TextField(default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
managed = True
|
||||
db_table = "organizaciones"
|
||||
|
||||
def __str__(self):
|
||||
label = self.org_name or "Sin nombre"
|
||||
return f"{label} [{self.org_id}]"
|
||||
|
||||
|
||||
class UserOrg(models.Model, JsonTextMixin):
|
||||
id = models.BigAutoField(primary_key=True, db_column="rowid")
|
||||
user = models.ForeignKey(User, db_column="user_id", on_delete=models.DO_NOTHING)
|
||||
org = models.ForeignKey(
|
||||
Organization,
|
||||
db_column="org_id",
|
||||
to_field="org_id",
|
||||
on_delete=models.DO_NOTHING,
|
||||
)
|
||||
role = models.TextField(default="")
|
||||
ea_aulas = models.TextField(default="[]")
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
managed = True
|
||||
db_table = "user_orgs"
|
||||
unique_together = (("user", "org"),)
|
||||
|
||||
@property
|
||||
def aulas(self):
|
||||
data = self.parse_json(self.ea_aulas, [])
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
@property
|
||||
def role_values(self):
|
||||
return parse_permission_values(self.role)
|
||||
|
||||
@property
|
||||
def role_display(self):
|
||||
values = [humanize_permission(value) for value in self.role_values]
|
||||
return ", ".join([value for value in values if value])
|
||||
|
||||
@property
|
||||
def permissions_values(self):
|
||||
return parse_permission_values(self.role)
|
||||
|
||||
@property
|
||||
def permissions_display(self):
|
||||
values = [humanize_permission(value) for value in self.permissions_values]
|
||||
return ", ".join([value for value in values if value])
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} -> {self.org} ({self.permissions_display or 'sin permisos'})"
|
||||
|
||||
|
||||
class Aulario(models.Model, JsonTextMixin):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
org = models.ForeignKey(
|
||||
Organization,
|
||||
db_column="org_id",
|
||||
to_field="org_id",
|
||||
on_delete=models.DO_NOTHING,
|
||||
)
|
||||
aulario_id = models.TextField(default=generate_short_uuid)
|
||||
name = models.TextField(default="")
|
||||
icon_file = models.FileField(upload_to=aulario_icon_upload_to, null=True, blank=True)
|
||||
extra = models.TextField(default="{}")
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
managed = True
|
||||
db_table = "aularios"
|
||||
|
||||
@property
|
||||
def payload(self):
|
||||
data = self.parse_json(self.extra, {})
|
||||
base = {"name": self.name}
|
||||
if isinstance(data, dict):
|
||||
base.update(data)
|
||||
return base
|
||||
|
||||
def __str__(self):
|
||||
label = self.name or self.aulario_id
|
||||
return f"{label} [{self.aulario_id}] - {self.org}"
|
||||
|
||||
|
||||
class UserSession(models.Model):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
session_token = models.TextField(unique=True)
|
||||
username = models.TextField()
|
||||
ip_address = models.TextField(default="")
|
||||
user_agent = models.TextField(default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_active = models.DateTimeField(auto_now=True)
|
||||
remember_token_hash = models.TextField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
managed = True
|
||||
db_table = "user_sessions"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} @ {self.ip_address or 'sin IP'}"
|
||||
|
||||
|
||||
class ClubEvent(models.Model, JsonTextMixin):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
date_ref = models.TextField(unique=True)
|
||||
data = models.TextField(default="{}")
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
managed = True
|
||||
db_table = "club_events"
|
||||
|
||||
@property
|
||||
def payload(self):
|
||||
data = self.parse_json(self.data, {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def __str__(self):
|
||||
return self.payload.get("title") or self.date_ref
|
||||
|
||||
|
||||
class Invitation(models.Model):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
code = models.TextField(unique=True)
|
||||
active = models.IntegerField(default=1)
|
||||
single_use = models.IntegerField(default=1)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
managed = True
|
||||
db_table = "invitations"
|
||||
|
||||
def __str__(self):
|
||||
return self.code
|
||||
74
django_app/core/shell.py
Normal file
74
django_app/core/shell.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from django.http import HttpRequest, HttpResponseForbidden
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.html import format_html
|
||||
|
||||
|
||||
APP_META = {
|
||||
"ax4": {"root": "/", "icon": "logo.png", "name": "Axia<sup>4</sup>", "title": "Axia4"},
|
||||
"account": {"root": "/account/", "icon": "logo-account.png", "name": "Mi Cuenta", "title": "Mi Cuenta"},
|
||||
"aulatek": {"root": "/aulatek/", "icon": "logo-entreaulas.png", "name": "AulaTek", "title": "AulaTek"},
|
||||
"club": {"root": "/club/", "icon": "logo-club.png", "name": "La web del Club<sup>3</sup>", "title": "La web del Club"},
|
||||
"sysadmin": {"root": "/sysadmin/", "icon": "logo-sysadmin.png", "name": "SysAdmin", "title": "SysAdmin"},
|
||||
}
|
||||
|
||||
|
||||
def safe_redirect(value: str, default: str = "/") -> str:
|
||||
if value and value.startswith("/") and not value.startswith("//"):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def require_axia_login(request: HttpRequest):
|
||||
if not getattr(request, "axia_user", None):
|
||||
return redirect(f"/login/?redir={request.get_full_path()}")
|
||||
return None
|
||||
|
||||
|
||||
def require_permission(request: HttpRequest, permission: str):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
if not request.axia_user.has_permission(permission):
|
||||
return HttpResponseForbidden("No tienes permiso para acceder a esta seccion.")
|
||||
return None
|
||||
|
||||
|
||||
def build_sidebar(app_code: str, request: HttpRequest):
|
||||
current_path = request.path
|
||||
if app_code == "account":
|
||||
items = [
|
||||
{"href": "/login/?reload_user=1", "label": "Recargar cuenta"},
|
||||
{"href": "/logout/", "label": "Cerrar sesion"},
|
||||
{"href": "/login/?logout=1", "label": "Limpiar sesion"},
|
||||
]
|
||||
elif app_code == "aulatek":
|
||||
items = [
|
||||
{"section": "Atajos", "href": "/aulatek/", "label": "Aulas"},
|
||||
{"href": "/aulatek/supercafe/", "label": "SuperCafe", "icon": "/static/iconexperience/purchase_order_cart.png"},
|
||||
]
|
||||
elif app_code == "sysadmin":
|
||||
items = [
|
||||
{"section": "Atajos", "href": "/sysadmin/users/", "label": "Nuevo usuario"},
|
||||
]
|
||||
elif app_code == "club":
|
||||
items = [
|
||||
{"href": "/club/upload/", "label": "Subir fotos"},
|
||||
]
|
||||
else:
|
||||
items = []
|
||||
|
||||
for item in items:
|
||||
item["active"] = current_path == item.get("href")
|
||||
return items
|
||||
|
||||
|
||||
def shell_context(request: HttpRequest, app_code: str, page_title: str):
|
||||
meta = APP_META[app_code]
|
||||
return {
|
||||
"page_title": page_title,
|
||||
"app_root": meta["root"],
|
||||
"app_icon": meta["icon"],
|
||||
"app_name": format_html(meta["name"]),
|
||||
"sidebar_links": build_sidebar(app_code, request),
|
||||
"app_code": app_code,
|
||||
}
|
||||
1
django_app/core/templatetags/__init__.py
Normal file
1
django_app/core/templatetags/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
14
django_app/core/templatetags/core_extras.py
Normal file
14
django_app/core/templatetags/core_extras.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def has_permission(user, permission):
|
||||
return bool(user and getattr(user, "has_permission", None) and user.has_permission(permission))
|
||||
|
||||
|
||||
@register.filter
|
||||
def startswith(value, prefix):
|
||||
return str(value).startswith(str(prefix))
|
||||
14
django_app/core/urls.py
Normal file
14
django_app/core/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "core"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
path("onboarding/django-admin/", views.django_admin_onboarding, name="django_admin_onboarding"),
|
||||
path("login/", views.login_view, name="login"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
path("switch-tenant/", views.switch_tenant, name="switch_tenant"),
|
||||
]
|
||||
126
django_app/core/views.py
Normal file
126
django_app/core/views.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import authenticate as django_authenticate
|
||||
from django.contrib.auth import get_user_model, login as django_login
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .auth import (
|
||||
authenticate as axia_authenticate,
|
||||
build_axia_user,
|
||||
login_user,
|
||||
logout_user,
|
||||
set_active_organization,
|
||||
)
|
||||
from .forms import DjangoAdminOnboardingForm, LoginForm
|
||||
from .shell import require_axia_login, safe_redirect, shell_context
|
||||
|
||||
|
||||
def home(request: HttpRequest):
|
||||
user = getattr(request, "axia_user", None)
|
||||
context = shell_context(request, "ax4", "Axia4")
|
||||
context["apps_cards"] = [
|
||||
{
|
||||
"icon": "/static/logo-club.png",
|
||||
"title": "La web del club",
|
||||
"description": "Acceso publico",
|
||||
"actions": [{"href": "/club/", "label": "Acceso publico", "variant": "primary"}],
|
||||
},
|
||||
{
|
||||
"icon": "/static/logo-account.png",
|
||||
"title": "Mi Cuenta",
|
||||
"description": "Acceso a la plataforma y pagos.",
|
||||
"actions": [
|
||||
{"href": "/account/" if user else "/login/?redir=/", "label": "Ir a mi cuenta" if user else "Iniciar sesion", "variant": "primary"},
|
||||
{"href": "/logout/", "label": "Cerrar sesion", "variant": "secondary"} if user else {"href": "/account/register/", "label": "Crear cuenta", "variant": "outline-primary"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"icon": "/static/logo-aulatek.png",
|
||||
"title": "AulaTek",
|
||||
"description": "Tu aula, digital.",
|
||||
"actions": [{"href": "/aulatek/", "label": "Acceso publico", "variant": "primary"}],
|
||||
},
|
||||
{
|
||||
"icon": "/static/logo-sysadmin.png",
|
||||
"title": "SysAdmin",
|
||||
"description": "Configuracion de Axia4.",
|
||||
"actions": [
|
||||
{"href": "/sysadmin/", "label": "Acceder", "variant": "primary"}
|
||||
if user and user.has_permission("sysadmin:access")
|
||||
else {"href": "", "label": "Sin permiso", "variant": "disabled"}
|
||||
],
|
||||
},
|
||||
]
|
||||
return render(request, "core/home.html", context)
|
||||
|
||||
|
||||
def login_view(request: HttpRequest):
|
||||
if request.GET.get("logout") == "1":
|
||||
logout_user(request)
|
||||
messages.info(request, "Sesion cerrada correctamente.")
|
||||
return redirect(safe_redirect(request.GET.get("redir", "/")))
|
||||
|
||||
if request.GET.get("reload_user") == "1" and getattr(request, "axia_user", None):
|
||||
request.axia_user = build_axia_user(request.axia_user.record, request)
|
||||
messages.success(request, "Cuenta recargada.")
|
||||
return redirect(safe_redirect(request.GET.get("redir", "/")))
|
||||
|
||||
if getattr(request, "axia_user", None):
|
||||
return redirect(safe_redirect(request.GET.get("redir", "/account/"), "/account/"))
|
||||
|
||||
form = LoginForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
user = axia_authenticate(form.cleaned_data["user"], form.cleaned_data["password"])
|
||||
if user:
|
||||
login_user(request, user)
|
||||
return redirect(safe_redirect(request.GET.get("redir", "/")))
|
||||
form.add_error(None, "Las credenciales no son correctas.")
|
||||
|
||||
context = shell_context(request, "ax4", "Iniciar sesion en Axia4")
|
||||
context["form"] = form
|
||||
return render(request, "core/login.html", context)
|
||||
|
||||
|
||||
def logout_view(request: HttpRequest):
|
||||
logout_user(request)
|
||||
messages.info(request, "Sesion cerrada.")
|
||||
return redirect("/")
|
||||
|
||||
|
||||
def django_admin_onboarding(request: HttpRequest):
|
||||
UserModel = get_user_model()
|
||||
if UserModel.objects.filter(is_superuser=True).exists():
|
||||
messages.info(request, "Ya existe una cuenta administradora de Django.")
|
||||
return redirect("/django-admin/login/")
|
||||
|
||||
form = DjangoAdminOnboardingForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
username = form.cleaned_data["username"]
|
||||
email = form.cleaned_data.get("email") or ""
|
||||
raw_password = form.cleaned_data["password1"]
|
||||
|
||||
UserModel.objects.create_superuser(username=username, email=email, password=raw_password)
|
||||
auth_user = django_authenticate(request, username=username, password=raw_password)
|
||||
if auth_user:
|
||||
django_login(request, auth_user)
|
||||
messages.success(request, "Cuenta administradora de Django creada correctamente.")
|
||||
return redirect("/django-admin/")
|
||||
|
||||
context = shell_context(request, "ax4", "Onboarding Django Admin")
|
||||
context["form"] = form
|
||||
return render(request, "core/onboarding_django_admin.html", context)
|
||||
|
||||
|
||||
@require_POST
|
||||
def switch_tenant(request: HttpRequest):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
organization = (request.POST.get("organization") or "").strip()
|
||||
allowed = {org["id"] for org in request.axia_user.organizations}
|
||||
if organization in allowed:
|
||||
set_active_organization(request, organization)
|
||||
return redirect(safe_redirect(request.POST.get("redir", "/account/"), "/account/"))
|
||||
|
||||
|
||||
14
django_app/manage.py
Normal file
14
django_app/manage.py
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "axia4_django.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
django_app/requirements.txt
Normal file
2
django_app/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Django>=5.1,<5.3
|
||||
bcrypt>=4.1,<5
|
||||
0
django_app/sysadmin/__init__.py
Normal file
0
django_app/sysadmin/__init__.py
Normal file
6
django_app/sysadmin/apps.py
Normal file
6
django_app/sysadmin/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SysadminConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "sysadmin"
|
||||
24
django_app/sysadmin/urls.py
Normal file
24
django_app/sysadmin/urls.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "sysadmin"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.sysadmin_home, name="sysadmin_home"),
|
||||
path("users/", views.sysadmin_users, name="sysadmin_users"),
|
||||
path("users/create/", views.sysadmin_user_create, name="sysadmin_user_create"),
|
||||
path("users/<int:user_id>/update/", views.sysadmin_user_update, name="sysadmin_user_update"),
|
||||
path("users/<int:user_id>/delete/", views.sysadmin_user_delete, name="sysadmin_user_delete"),
|
||||
path("orgs/", views.sysadmin_orgs, name="sysadmin_orgs"),
|
||||
path("orgs/create/", views.sysadmin_org_create, name="sysadmin_org_create"),
|
||||
path("orgs/<int:org_id>/update/", views.sysadmin_org_update, name="sysadmin_org_update"),
|
||||
path("orgs/<int:org_id>/delete/", views.sysadmin_org_delete, name="sysadmin_org_delete"),
|
||||
path("invitations/", views.sysadmin_invitations, name="sysadmin_invitations"),
|
||||
path("invitations/create/", views.sysadmin_invitation_create, name="sysadmin_invitation_create"),
|
||||
path("invitations/<int:invitation_id>/toggle/", views.sysadmin_invitation_toggle, name="sysadmin_invitation_toggle"),
|
||||
path("invitations/<int:invitation_id>/delete/", views.sysadmin_invitation_delete, name="sysadmin_invitation_delete"),
|
||||
path("aularios/", views.sysadmin_aularios, name="sysadmin_aularios"),
|
||||
path("club-mkthumb/", views.club_mkthumb, name="club_mkthumb"),
|
||||
]
|
||||
273
django_app/sysadmin/views.py
Normal file
273
django_app/sysadmin/views.py
Normal file
@@ -0,0 +1,273 @@
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from core.auth import build_axia_user
|
||||
from core.forms import SysadminInvitationForm, SysadminOrganizationForm, SysadminUserForm
|
||||
from core.models import Aulario, Invitation, Organization, User, UserOrg, UserSession
|
||||
from core.shell import require_permission, shell_context
|
||||
|
||||
|
||||
def sysadmin_home(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
context = shell_context(request, "sysadmin", "Administracion del Sistema")
|
||||
context["tiles"] = [
|
||||
{
|
||||
"title": "AulaTek",
|
||||
"icon": "/static/logo-entreaulas.png",
|
||||
"actions": [
|
||||
{"href": "/sysadmin/orgs/", "label": "Gestionar Organizaciones"},
|
||||
{"href": "/sysadmin/aularios/", "label": "Gestionar Aularios"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Mi Cuenta",
|
||||
"icon": "/static/logo-account.png",
|
||||
"actions": [
|
||||
{"href": "/sysadmin/users/", "label": "Gestionar Usuarios"},
|
||||
{"href": "/sysadmin/invitations/", "label": "Gestionar Invitaciones"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "La web del Club",
|
||||
"icon": "/static/logo-club.png",
|
||||
"actions": [{"href": "/sysadmin/club-mkthumb/", "label": "Generar Miniaturas"}],
|
||||
},
|
||||
]
|
||||
return render(request, "core/sysadmin_index.html", context)
|
||||
|
||||
|
||||
def sysadmin_users(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
edit_id = (request.GET.get("edit") or "").strip()
|
||||
edit_user = User.objects.filter(id=edit_id).first() if edit_id.isdigit() else None
|
||||
users = [build_axia_user(row, request) for row in User.objects.all().order_by("username")]
|
||||
|
||||
context = shell_context(request, "sysadmin", "Gestionar Usuarios")
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Gestionar Usuarios",
|
||||
"users": users,
|
||||
"create_form": SysadminUserForm(prefix="new"),
|
||||
"edit_user": edit_user,
|
||||
"edit_form": SysadminUserForm(instance=edit_user, prefix="edit") if edit_user else None,
|
||||
}
|
||||
)
|
||||
return render(request, "core/sysadmin_users.html", context)
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_user_create(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
form = SysadminUserForm(request.POST, prefix="new")
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, "Usuario creado correctamente.")
|
||||
else:
|
||||
messages.error(request, f"No se pudo crear el usuario: {form.errors.as_text()}")
|
||||
return redirect("/sysadmin/users/")
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_user_update(request: HttpRequest, user_id: int):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
form = SysadminUserForm(request.POST, instance=user, prefix="edit")
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, "Usuario actualizado correctamente.")
|
||||
return redirect("/sysadmin/users/")
|
||||
|
||||
messages.error(request, f"No se pudo actualizar el usuario: {form.errors.as_text()}")
|
||||
return redirect(f"/sysadmin/users/?edit={user_id}")
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_user_delete(request: HttpRequest, user_id: int):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
if getattr(request.axia_user.record, "id", None) == user.id:
|
||||
messages.error(request, "No puedes eliminar tu propio usuario mientras estas autenticado.")
|
||||
return redirect("/sysadmin/users/")
|
||||
|
||||
UserOrg.objects.filter(user_id=user.id).delete()
|
||||
UserSession.objects.filter(username__iexact=user.username).delete()
|
||||
user.delete()
|
||||
messages.success(request, "Usuario eliminado correctamente.")
|
||||
return redirect("/sysadmin/users/")
|
||||
|
||||
|
||||
def sysadmin_orgs(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
edit_id = (request.GET.get("edit") or "").strip()
|
||||
edit_org = Organization.objects.filter(id=edit_id).first() if edit_id.isdigit() else None
|
||||
|
||||
context = shell_context(request, "sysadmin", "Gestionar Organizaciones")
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Gestionar Organizaciones",
|
||||
"organizations": Organization.objects.all().order_by("org_id"),
|
||||
"create_form": SysadminOrganizationForm(prefix="new"),
|
||||
"edit_org": edit_org,
|
||||
"edit_form": SysadminOrganizationForm(instance=edit_org, prefix="edit") if edit_org else None,
|
||||
}
|
||||
)
|
||||
return render(request, "core/sysadmin_orgs.html", context)
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_org_create(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
form = SysadminOrganizationForm(request.POST, prefix="new")
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, "Organizacion creada correctamente.")
|
||||
else:
|
||||
messages.error(request, f"No se pudo crear la organizacion: {form.errors.as_text()}")
|
||||
return redirect("/sysadmin/orgs/")
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_org_update(request: HttpRequest, org_id: int):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
org = get_object_or_404(Organization, id=org_id)
|
||||
form = SysadminOrganizationForm(request.POST, instance=org, prefix="edit")
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, "Organizacion actualizada correctamente.")
|
||||
return redirect("/sysadmin/orgs/")
|
||||
|
||||
messages.error(request, f"No se pudo actualizar la organizacion: {form.errors.as_text()}")
|
||||
return redirect(f"/sysadmin/orgs/?edit={org_id}")
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_org_delete(request: HttpRequest, org_id: int):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
org = get_object_or_404(Organization, id=org_id)
|
||||
linked_users = UserOrg.objects.filter(org_id=org.org_id).count()
|
||||
linked_aularios = Aulario.objects.filter(org_id=org.org_id).count()
|
||||
if linked_users or linked_aularios:
|
||||
messages.error(
|
||||
request,
|
||||
f"No se puede eliminar: hay {linked_users} relaciones de usuarios y {linked_aularios} aularios asociados.",
|
||||
)
|
||||
return redirect("/sysadmin/orgs/")
|
||||
|
||||
org.delete()
|
||||
messages.success(request, "Organizacion eliminada correctamente.")
|
||||
return redirect("/sysadmin/orgs/")
|
||||
|
||||
|
||||
def sysadmin_invitations(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
context = shell_context(request, "sysadmin", "Gestionar Invitaciones")
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Gestionar Invitaciones",
|
||||
"invitations": Invitation.objects.all().order_by("code"),
|
||||
"create_form": SysadminInvitationForm(prefix="new", initial={"active": True, "single_use": True}),
|
||||
}
|
||||
)
|
||||
return render(request, "core/sysadmin_invitations.html", context)
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_invitation_create(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
form = SysadminInvitationForm(request.POST, prefix="new")
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, "Invitacion creada correctamente.")
|
||||
else:
|
||||
messages.error(request, f"No se pudo crear la invitacion: {form.errors.as_text()}")
|
||||
return redirect("/sysadmin/invitations/")
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_invitation_toggle(request: HttpRequest, invitation_id: int):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
invitation = get_object_or_404(Invitation, id=invitation_id)
|
||||
invitation.active = 0 if int(invitation.active or 0) else 1
|
||||
invitation.save(update_fields=["active"])
|
||||
messages.success(request, "Estado de invitacion actualizado.")
|
||||
return redirect("/sysadmin/invitations/")
|
||||
|
||||
|
||||
@require_POST
|
||||
def sysadmin_invitation_delete(request: HttpRequest, invitation_id: int):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
invitation = get_object_or_404(Invitation, id=invitation_id)
|
||||
invitation.delete()
|
||||
messages.success(request, "Invitacion eliminada correctamente.")
|
||||
return redirect("/sysadmin/invitations/")
|
||||
|
||||
|
||||
def sysadmin_aularios(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
grouped = {}
|
||||
for row in Aulario.objects.select_related("org").order_by("org__org_id", "aulario_id"):
|
||||
grouped.setdefault(row.org.org_id, []).append(row)
|
||||
|
||||
context = shell_context(request, "sysadmin", "Gestionar Aularios")
|
||||
context.update({"module_title": "Gestionar Aularios", "grouped_aularios": grouped})
|
||||
return render(request, "core/sysadmin_aularios.html", context)
|
||||
|
||||
|
||||
def club_mkthumb(request: HttpRequest):
|
||||
gate = require_permission(request, "sysadmin:access")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
context = shell_context(request, "sysadmin", "Generar Miniaturas")
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Generar Miniaturas",
|
||||
"module_description": "Esta utilidad aun no esta migrada.",
|
||||
"tiles": [],
|
||||
}
|
||||
)
|
||||
return render(request, "core/module.html", context)
|
||||
45
django_app/templates/aulatek/alumnos.html
Normal file
45
django_app/templates/aulatek/alumnos.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>{{ module_title }}</h1>
|
||||
{% if aulario_id %}
|
||||
<p>Aulario: <strong>{{ aulario_id }}</strong></p>
|
||||
<a class="btn btn-primary" href="/aulatek/alumnos/new/?aulario={{ aulario_id }}">Nuevo alumno</a>
|
||||
{% else %}
|
||||
<p>No se ha indicado un aulario valido.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if aulario_id %}
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:16px;">
|
||||
{% for student in students %}
|
||||
<div class="card pad">
|
||||
<div style="display:flex; gap:12px; align-items:center; margin-bottom:12px;">
|
||||
{% if student.has_photo %}
|
||||
<img src="/aulatek/alumnos/{{ student.id }}/foto/" alt="{{ student.name }}" style="width:64px; height:64px; border-radius:50%; object-fit:cover; border:1px solid #dadce0;">
|
||||
{% else %}
|
||||
<div style="width:64px; height:64px; border-radius:50%; background:#e8f0fe; color:#1a73e8; display:flex; align-items:center; justify-content:center; font-weight:700; font-size: 35px;">
|
||||
{{ student.name|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div style="font-weight:700;">{{ student.name }}</div>
|
||||
<div style="color:#5f6368; font-size:.9rem;">Alumno</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; margin-top:10px;">
|
||||
<a class="btn btn-outline-primary btn-sm" href="/aulatek/alumnos/edit/{{ student.id }}/">Editar</a>
|
||||
<form method="post" action="/aulatek/alumnos/delete/{{ student.id }}/" onsubmit="return confirm('¿Eliminar este alumno?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="card pad">Todavia no hay alumnos en este aulario.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
29
django_app/templates/aulatek/alumnos_edit.html
Normal file
29
django_app/templates/aulatek/alumnos_edit.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>{{ module_title }}</h1>
|
||||
<p>Aulario: <strong>{{ student.aulario_id }}</strong></p>
|
||||
|
||||
<form method="post" action="/aulatek/alumnos/edit/{{ student.id }}/" enctype="multipart/form-data" style="display:grid; gap:12px; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<label for="nombre_new" class="form-label">Nombre</label>
|
||||
<input id="nombre_new" name="nombre_new" value="{{ student.alumno }}" class="form-control" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="photo" class="form-label">Actualizar foto</label>
|
||||
<input id="photo" name="photo" type="file" class="form-control" accept="image/*">
|
||||
</div>
|
||||
{% if student.photo %}
|
||||
<div style="grid-column:1/-1;">
|
||||
<img src="/aulatek/alumnos/{{ student.id }}/foto/" alt="{{ student.alumno }}" style="width:84px; height:84px; border-radius:50%; object-fit:cover; border:1px solid #dadce0;">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="grid-column:1/-1; display:flex; gap:8px;">
|
||||
<button type="submit" class="btn btn-primary">Guardar cambios</button>
|
||||
<a class="btn btn-outline-secondary" href="/aulatek/alumnos/?aulario={{ student.aulario_id }}">Volver</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
23
django_app/templates/aulatek/alumnos_new.html
Normal file
23
django_app/templates/aulatek/alumnos_new.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>{{ module_title }}</h1>
|
||||
<p>Aulario: <strong>{{ aulario_id }}</strong></p>
|
||||
<form method="post" action="/aulatek/alumnos/new/?aulario={{ aulario_id }}" enctype="multipart/form-data" style="display:grid; gap:12px; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<label for="nombre" class="form-label">Nombre</label>
|
||||
<input id="nombre" name="nombre" class="form-control" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="photo" class="form-label">Foto (opcional)</label>
|
||||
<input id="photo" name="photo" type="file" class="form-control" accept="image/*">
|
||||
</div>
|
||||
<div style="grid-column:1/-1; display:flex; gap:8px;">
|
||||
<button type="submit" class="btn btn-primary">Crear alumno</button>
|
||||
<a class="btn btn-outline-secondary" href="/aulatek/alumnos/?aulario={{ aulario_id }}">Volver</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
64
django_app/templates/aulatek/aulario.html
Normal file
64
django_app/templates/aulatek/aulario.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block page_css %}
|
||||
.tile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.grid-item {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.grid-item img {
|
||||
margin: 0 auto;
|
||||
height: 125px;
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.tile-fallback {
|
||||
width: 125px;
|
||||
height: 125px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
font-size: 54px;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1 class="card-title">Aulario: {{ aulario.name }}</h1>
|
||||
<span>Bienvenidx al aulario {{ aulario.name }}. Aqui podras gestionar las funcionalidades especificas de este aulario.</span>
|
||||
</div>
|
||||
|
||||
{% if tiles %}
|
||||
<div class="tile-grid">
|
||||
{% for tile in tiles %}
|
||||
{% if tile.visible != False %}
|
||||
<a href="{{ tile.href }}" class="btn btn-{{ tile.variant|default:'primary' }} grid-item">
|
||||
{% if tile.icon %}
|
||||
<img src="{{ tile.icon }}" alt="{{ tile.label }}">
|
||||
{% else %}
|
||||
<div class="tile-fallback">□</div>
|
||||
{% endif %}
|
||||
<span>{{ tile.label }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
365
django_app/templates/aulatek/comedor.html
Normal file
365
django_app/templates/aulatek/comedor.html
Normal file
@@ -0,0 +1,365 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block page_css %}
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
}
|
||||
.menu-card {
|
||||
position: relative;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
margin-bottom: 0;
|
||||
width: auto;
|
||||
}
|
||||
.menu-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.menu-images {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.menu-img-block {
|
||||
text-align: center;
|
||||
}
|
||||
.menu-img-label {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.menu-img {
|
||||
max-width: 100%;
|
||||
height: 140px;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
border: 2px solid #ddd;
|
||||
}
|
||||
.menu-placeholder {
|
||||
height: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f1f1;
|
||||
border-radius: 12px;
|
||||
border: 2px dashed #aaa;
|
||||
color: #666;
|
||||
font-weight: 700;
|
||||
}
|
||||
.menu-name {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
background: #fff3cd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.top-controls-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.control-label {
|
||||
margin: 0;
|
||||
min-width: 64px;
|
||||
}
|
||||
.aulario-select {
|
||||
max-width: 320px;
|
||||
display: inline-block;
|
||||
}
|
||||
.label-muted {
|
||||
color: #5f6368;
|
||||
font-size: .9rem;
|
||||
}
|
||||
.type-pill {
|
||||
color: #fff;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
.type-pill.active {
|
||||
border-color: #000;
|
||||
}
|
||||
.edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.top-controls-grid {
|
||||
grid-template-columns: 300px 300px auto;
|
||||
align-items: center;
|
||||
}
|
||||
.top-controls-grid .control-row {
|
||||
margin-top: 0 !important;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.field-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.edit-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1 class="card-title">Menu del Comedor</h1>
|
||||
<div class="top-controls-grid">
|
||||
<div class="control-row">
|
||||
<label for="aularioPicker" class="form-label control-label"><b>Aulario:</b></label>
|
||||
<select id="aularioPicker" class="form-select aulario-select">
|
||||
{% for row in aulario_options %}
|
||||
<option value="{{ row.aulario_id }}" {% if row.aulario_id == aulario_id %}selected{% endif %}>{{ row.name|default:row.aulario_id }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-row" style="gap: 0;">
|
||||
<label for="datePicker" class="form-label control-label"><b>Fecha:</b></label>
|
||||
<div class="input-group" style="width: auto;">
|
||||
<a class="btn btn-outline-dark" href="/aulatek/comedor/?aulario={{ aulario_id }}&date={{ prev_date|date:'Y-m-d' }}&menu={{ selected_menu_type|urlencode }}">←</a>
|
||||
<input type="date" id="datePicker" class="form-control date-input" value="{{ selected_date|date:'Y-m-d' }}">
|
||||
<a class="btn btn-outline-dark" href="/aulatek/comedor/?aulario={{ aulario_id }}&date={{ next_date|date:'Y-m-d' }}&menu={{ selected_menu_type|urlencode }}">→</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-row" style="justify-content: right;">
|
||||
{% for type in menu_types %}
|
||||
<a href="/aulatek/comedor/?aulario={{ aulario_id }}&date={{ selected_date|date:'Y-m-d' }}&menu={{ type.id|urlencode }}" class="btn type-pill{% if type.active %} active{% endif %}" data-type-color="{{ type.color }}">{{ type.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if selected_type and not can_edit_selected_type %}
|
||||
<div class="card pad" style="background:#cfe2ff; color:#084298;">
|
||||
<strong>Datos compartidos:</strong> Este tipo de menu se edita en su aulario origen. Aqui solo puedes consultarlo.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if selected_menu %}
|
||||
<div class="menu-grid" style="margin-bottom:12px;">
|
||||
<div class="card pad menu-card">
|
||||
<h3 class="menu-title">Primer plato</h3>
|
||||
<div class="menu-images">
|
||||
<div class="menu-img-block">
|
||||
<div class="menu-img-label">Foto</div>
|
||||
{% if selected_menu.obj.first_photo %}
|
||||
<img class="menu-img" src="{{ selected_menu.obj.first_photo.url }}" alt="Primer plato">
|
||||
{% else %}
|
||||
<div class="menu-placeholder">Sin foto</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-name">{{ selected_menu.obj.first_name|default:'Sin nombre' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card pad menu-card">
|
||||
<h3 class="menu-title">Segundo plato</h3>
|
||||
<div class="menu-images">
|
||||
<div class="menu-img-block">
|
||||
<div class="menu-img-label">Foto</div>
|
||||
{% if selected_menu.obj.second_photo %}
|
||||
<img class="menu-img" src="{{ selected_menu.obj.second_photo.url }}" alt="Segundo plato">
|
||||
{% else %}
|
||||
<div class="menu-placeholder">Sin foto</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-name">{{ selected_menu.obj.second_name|default:'Sin nombre' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card pad menu-card">
|
||||
<h3 class="menu-title">Postre</h3>
|
||||
<div class="menu-images">
|
||||
<div class="menu-img-block">
|
||||
<div class="menu-img-label">Foto</div>
|
||||
{% if selected_menu.obj.dessert_photo %}
|
||||
<img class="menu-img" src="{{ selected_menu.obj.dessert_photo.url }}" alt="Postre">
|
||||
{% else %}
|
||||
<div class="menu-placeholder">Sin foto</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-name">{{ selected_menu.obj.dessert_name|default:'Sin nombre' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card pad">No hay menu para la fecha y tipo seleccionados.</div>
|
||||
{% endif %}
|
||||
|
||||
<details class="card pad">
|
||||
<summary><strong class="btn btn-primary">{% if selected_menu %}Editar menu{% else %}Crear menu{% endif %}</strong></summary>
|
||||
<form method="post" enctype="multipart/form-data" style="margin-top:10px;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save">
|
||||
{% if selected_menu %}<input type="hidden" name="menu_id" value="{{ selected_menu.obj.id }}">{% endif %}
|
||||
|
||||
<div class="field-grid" style="margin-bottom:10px;">
|
||||
<div>
|
||||
<label class="form-label">Fecha</label>
|
||||
<input class="form-control" type="date" name="menu_date" value="{{ selected_date|date:'Y-m-d' }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edit-grid" style="margin-bottom:10px;">
|
||||
<div class="card pad" style="background:#f8f9fa;">
|
||||
<h4>Primer plato</h4>
|
||||
<label class="form-label">Nombre</label>
|
||||
<input class="form-control" type="text" name="first_name" value="{% if selected_menu %}{{ selected_menu.obj.first_name }}{% endif %}" required>
|
||||
<label class="form-label" style="margin-top:8px;">Foto</label>
|
||||
<input class="form-control" type="file" name="{% if selected_menu %}first_photo_{{ selected_menu.obj.id }}{% else %}first_photo{% endif %}" accept="image/*">
|
||||
</div>
|
||||
<div class="card pad" style="background:#f8f9fa;">
|
||||
<h4>Segundo plato</h4>
|
||||
<label class="form-label">Nombre</label>
|
||||
<input class="form-control" type="text" name="second_name" value="{% if selected_menu %}{{ selected_menu.obj.second_name }}{% endif %}" required>
|
||||
<label class="form-label" style="margin-top:8px;">Foto</label>
|
||||
<input class="form-control" type="file" name="{% if selected_menu %}second_photo_{{ selected_menu.obj.id }}{% else %}second_photo{% endif %}" accept="image/*">
|
||||
</div>
|
||||
|
||||
<div class="card pad" style="background:#f8f9fa;">
|
||||
<h4>Postre</h4>
|
||||
<label class="form-label">Nombre</label>
|
||||
<input class="form-control" type="text" name="dessert_name" value="{% if selected_menu %}{{ selected_menu.obj.dessert_name }}{% endif %}" required>
|
||||
<label class="form-label" style="margin-top:8px;">Foto</label>
|
||||
<input class="form-control" type="file" name="{% if selected_menu %}dessert_photo_{{ selected_menu.obj.id }}{% else %}dessert_photo{% endif %}" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success" type="submit" {% if not can_edit_selected_type %}disabled{% endif %}>{% if selected_menu %}Guardar menu{% else %}Crear menu{% endif %}</button>
|
||||
</form>
|
||||
|
||||
{% if selected_menu %}
|
||||
<form method="post" style="margin-top:10px;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="menu_id" value="{{ selected_menu.obj.id }}">
|
||||
<button class="btn btn-outline-danger" type="submit" {% if not can_edit_selected_type %}disabled{% endif %}>Eliminar menu</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</details>
|
||||
|
||||
<details class="card pad">
|
||||
<summary><strong class="btn btn-primary">Compartir tipo de menu</strong></summary>
|
||||
<form method="post" style="margin-top:10px;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="share_type">
|
||||
<div class="field-grid" style="margin-bottom:10px;">
|
||||
{% for row in all_aularios %}
|
||||
<label style="display:flex; align-items:center; gap:8px; border:1px solid #dadce0; border-radius:8px; padding:8px;">
|
||||
<input type="checkbox" name="shared_aularios" value="{{ row.id }}" {% if selected_menu and row.id in selected_menu.shared_ids %}checked{% endif %} {% if not can_edit_selected_type %}disabled{% endif %}>
|
||||
<span>{{ row.name|default:row.aulario_id }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit" {% if not can_edit_selected_type %}disabled{% endif %}>Guardar comparticion del tipo</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="card pad">
|
||||
<summary><strong class="btn btn-primary">Administrar tipos de menu</strong></summary>
|
||||
|
||||
<div style="margin-top:15px; padding:15px; background:#e7f1ff; border-radius:8px;">
|
||||
<h4 style="margin-bottom:10px;">Anadir nuevo tipo</h4>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_type">
|
||||
<div class="field-grid">
|
||||
<div>
|
||||
<label class="form-label">ID</label>
|
||||
<input type="text" name="new_type_id" class="form-control" placeholder="basal" {% if not can_edit_selected_type %}disabled{% endif %} required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Nombre</label>
|
||||
<input type="text" name="new_type_label" class="form-control" placeholder="Menu basal" {% if not can_edit_selected_type %}disabled{% endif %} required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Color</label>
|
||||
<input type="color" name="new_type_color" class="form-control" value="#0d6efd" {% if not can_edit_selected_type %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit" style="margin-top:10px;" {% if not can_edit_selected_type %}disabled{% endif %}>Anadir tipo</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px; padding:15px; background:#fff3cd; border-radius:8px;">
|
||||
<h4 style="margin-bottom:15px;">Tipos existentes</h4>
|
||||
{% for type in menu_types %}
|
||||
<div style="margin-bottom:12px; padding:12px; background:white; border-radius:6px; border:2px solid #ddd;" data-type-border="{{ type.color }}">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong>{{ type.label }}</strong><br>
|
||||
<small>ID: {{ type.id }}</small>
|
||||
</div>
|
||||
{% if type.is_owner %}
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<form method="post" style="display:flex; gap:8px; align-items:center;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="rename_type">
|
||||
<input type="hidden" name="rename_type_id" value="{{ type.id }}">
|
||||
<input class="form-control" type="text" name="rename_type_label" value="{{ type.label }}" style="max-width:200px;">
|
||||
<input class="form-control" type="color" name="rename_type_color" value="{{ type.color }}" style="max-width:70px;">
|
||||
<button class="btn btn-warning" type="submit">Renombrar</button>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('Se eliminara el tipo y todos sus menus por fecha. Continuar?');">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_type">
|
||||
<input type="hidden" name="delete_type_id" value="{{ type.id }}">
|
||||
<button class="btn btn-danger" type="submit">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="badge text-bg-secondary">Compartido</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
const datePicker = document.getElementById('datePicker');
|
||||
const aularioPicker = document.getElementById('aularioPicker');
|
||||
const selectedMenu = "{{ selected_menu_type|escapejs }}";
|
||||
document.querySelectorAll('.type-pill[data-type-color]').forEach(function(el) {
|
||||
el.style.background = el.getAttribute('data-type-color') || '#0d6efd';
|
||||
});
|
||||
document.querySelectorAll('[data-type-border]').forEach(function(el) {
|
||||
el.style.borderColor = el.getAttribute('data-type-border') || '#ddd';
|
||||
});
|
||||
function goToSelection() {
|
||||
if (!datePicker || !aularioPicker || !datePicker.value || !aularioPicker.value) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('aulario', aularioPicker.value);
|
||||
params.set('date', datePicker.value);
|
||||
params.set('menu', selectedMenu || 'Basal');
|
||||
window.location.href = '/aulatek/comedor/?' + params.toString();
|
||||
}
|
||||
if (datePicker) {
|
||||
datePicker.addEventListener('change', goToSelection);
|
||||
}
|
||||
if (aularioPicker) {
|
||||
aularioPicker.addEventListener('change', goToSelection);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
58
django_app/templates/aulatek/index.html
Normal file
58
django_app/templates/aulatek/index.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% load core_extras %}
|
||||
|
||||
{% block page_css %}
|
||||
#grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.grid-item {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.grid-item img {
|
||||
margin: 0 auto;
|
||||
height: 125px;
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
max-width: 100%;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1 class="card-title">Hola, {{ axia_user.display_name }}.</h1>
|
||||
<span>Bienvenidx a la plataforma de gestion de aularios conectados. Desde aqui podras administrar los aularios asociados a tu cuenta.</span>
|
||||
</div>
|
||||
<div id="grid">
|
||||
{% for aulario in aularios %}
|
||||
<a href="/aulatek/aulario/?id={{ aulario.id }}" class="btn btn-primary grid-item">
|
||||
<img src="{{ aulario.icon }}" alt="{{ aulario.name }}">
|
||||
<span>{{ aulario.name }}</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="card pad">No hay aularios asignados para la organizacion activa.</div>
|
||||
{% endfor %}
|
||||
{% if axia_user|has_permission:'supercafe:access' %}
|
||||
<a href="/aulatek/supercafe/" class="btn btn-warning grid-item">
|
||||
<img src="/static/iconexperience/purchase_order_cart.png" alt="SuperCafe" style="background:white; padding:5px; border-radius:10px;">
|
||||
<span>SuperCafe</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<!-- No Masonry necesario, CSS Grid se encarga del layout -->
|
||||
{% endblock %}
|
||||
99
django_app/templates/core/account.html
Normal file
99
django_app/templates/core/account.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block page_css %}
|
||||
.account-grid { display: flex; flex-wrap: wrap; gap: 16px; }
|
||||
.account-card-panel { background: #fff; border: 1px solid #e0e0e0; border-radius: 12px; padding: 24px; min-width: 280px; flex: 1 1 280px; }
|
||||
.account-card-panel h2 { font-size: 1rem; font-weight: 600; color: #5f6368; text-transform: uppercase; letter-spacing: .05em; margin: 0 0 16px; }
|
||||
.avatar-lg { width: 80px; height: 80px; border-radius: 50%; background: #1a73e8; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 2rem; font-weight: 700; margin: 0 auto 12px; }
|
||||
.info-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f1f3f4; font-size: .9rem; gap: 12px; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-row .label { color: #5f6368; }
|
||||
.badge-pill { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: .78rem; font-weight: 600; margin: 2px; }
|
||||
.badge-active { background: #e6f4ea; color: #137333; }
|
||||
.badge-perm { background: #e8f0fe; color: #1a73e8; }
|
||||
.tenant-btn { display: block; width: 100%; text-align: left; padding: 8px 12px; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 8px; background: #f8f9fa; cursor: pointer; font-size: .9rem; }
|
||||
.tenant-btn.active-tenant { border-color: #1a73e8; background: #e8f0fe; font-weight: 600; }
|
||||
.device-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #f1f3f4; }
|
||||
.device-row:last-child { border-bottom: none; }
|
||||
.device-icon { font-size: 1.6rem; flex-shrink: 0; }
|
||||
.device-info { flex: 1; min-width: 0; }
|
||||
.device-name { font-weight: 600; font-size: .9rem; }
|
||||
.device-meta { font-size: .78rem; color: #5f6368; margin-top: 2px; }
|
||||
.device-current { font-size: .75rem; color: #137333; font-weight: 600; background: #e6f4ea; border-radius: 99px; padding: 1px 8px; }
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="account-grid">
|
||||
<div class="account-card-panel" style="text-align:center;">
|
||||
<h2>Mi Perfil</h2>
|
||||
<div class="avatar-lg">{{ axia_user.initials }}</div>
|
||||
<div style="font-size:1.2rem; font-weight:700; margin-bottom:4px;">{{ axia_user.display_name }}</div>
|
||||
<div style="color:#5f6368; margin-bottom:16px;">{{ axia_user.email|default:'Sin correo' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="account-card-panel" style="text-align:center;">
|
||||
<h2>Codigo QR de Acceso</h2>
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=150x150&data={{ axia_user.username }}" alt="QR" style="margin:0 auto 12px; display:block; width:150px; height:150px;">
|
||||
<small style="color:#5f6368;">Escanea este codigo para iniciar sesion rapidamente.</small>
|
||||
</div>
|
||||
|
||||
{% if axia_user.organizations %}
|
||||
<div class="account-card-panel">
|
||||
<h2>Organizaciones</h2>
|
||||
{% for org in axia_user.organizations %}
|
||||
<form method="post" action="/switch-tenant/" style="margin:0;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="redir" value="/account/">
|
||||
<button type="submit" name="organization" value="{{ org.id }}" class="tenant-btn{% if org.id == axia_user.active_org %} active-tenant{% endif %}">
|
||||
{% if org.id == axia_user.active_org %}<span style="color:#1a73e8;">✓ </span>{% endif %}
|
||||
{{ org.name }}
|
||||
{% if org.id == axia_user.active_org %}<span class="badge-pill badge-active" style="float:right;">Activa</span>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if axia_user.aulas %}
|
||||
<div class="account-card-panel">
|
||||
<h2>Mis Aulas ({{ organization_name }})</h2>
|
||||
{% for aula in axia_user.aulas %}
|
||||
<div class="info-row"><span>{{ aula }}</span><span class="badge-pill badge-active">Asignada</span></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if axia_user.permissions %}
|
||||
<div class="account-card-panel">
|
||||
<h2>Permisos</h2>
|
||||
<div>
|
||||
{% for permission in axia_user.permissions %}
|
||||
<span class="badge-pill badge-perm">{{ permission }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="account-card-panel">
|
||||
<h2>Sesion Activa</h2>
|
||||
<div class="info-row"><span class="label">Org. activa</span><span>{{ organization_name|default:'-' }}</span></div>
|
||||
<div class="info-row"><span class="label">Autenticacion</span><span>{% if axia_user.google_auth %}Google{% else %}Contrasena{% endif %}</span></div>
|
||||
<div style="margin-top:16px;"><a href="/logout/" class="btn btn-danger btn-sm">Cerrar sesion</a></div>
|
||||
</div>
|
||||
|
||||
{% if connected_sessions %}
|
||||
<div class="account-card-panel" style="flex-basis:100%;">
|
||||
<h2>Dispositivos conectados</h2>
|
||||
{% for item in connected_sessions %}
|
||||
<div class="device-row">
|
||||
<div class="device-icon">{{ item.ua.icon }}</div>
|
||||
<div class="device-info">
|
||||
<div class="device-name">{{ item.label }} {% if item.current %}<span class="device-current">Este dispositivo</span>{% endif %}</div>
|
||||
<div class="device-meta">IP: {{ item.session.ip_address|default:'-' }} · Inicio: {{ item.session.created_at|slice:':16' }} · Activo: {{ item.session.last_active|slice:':16' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
554
django_app/templates/core/base.html
Normal file
554
django_app/templates/core/base.html
Normal file
@@ -0,0 +1,554 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ page_title }}</title>
|
||||
<link rel="stylesheet" href="{% static 'bootstrap.min.css' %}">
|
||||
<link rel="icon" type="image/png" href="{% static app_icon %}">
|
||||
<link rel="manifest" href="{% static 'manifest.json' %}">
|
||||
<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=Roboto:wght@300;400;500;700&family=Google+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--gw-font: 'Google Sans', 'Roboto', 'Arial', sans-serif;
|
||||
--gw-blue: #1a73e8;
|
||||
--gw-blue-hover: #1765cc;
|
||||
--gw-blue-light: #e8f0fe;
|
||||
--gw-text-primary: #202124;
|
||||
--gw-text-secondary: #5f6368;
|
||||
--gw-bg: #f0f4f9;
|
||||
--gw-surface: #ffffff;
|
||||
--gw-border: #dadce0;
|
||||
--gw-hover: #f1f3f4;
|
||||
--gw-header-h: 64px;
|
||||
--gw-sidebar-w: 256px;
|
||||
--gw-brand: #9013fe;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--gw-font);
|
||||
background: var(--gw-bg);
|
||||
color: var(--gw-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a { text-decoration: none; }
|
||||
|
||||
.btn {
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--gw-font);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--gw-blue);
|
||||
border-color: var(--gw-blue);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--gw-blue-hover);
|
||||
border-color: var(--gw-blue-hover);
|
||||
}
|
||||
|
||||
.card.pad {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--gw-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: none;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.sidebar-toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.axia-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--gw-surface);
|
||||
border-bottom: 1px solid var(--gw-border);
|
||||
padding: 0 8px;
|
||||
height: var(--gw-header-h);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--gw-text-secondary);
|
||||
border: none;
|
||||
transition: background 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover { background: var(--gw-hover); }
|
||||
|
||||
.logo-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--gw-text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo-area:hover {
|
||||
color: var(--gw-text-primary);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-bar form { display: flex; }
|
||||
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--gw-border);
|
||||
background: var(--gw-hover);
|
||||
padding: 8px 20px;
|
||||
border-radius: 24px;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
font-family: var(--gw-font);
|
||||
color: var(--gw-text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-button, .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--gw-text-secondary);
|
||||
list-style: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
background: var(--gw-blue);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.avatar.big {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
font-size: 1.7rem;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
.dot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 4px);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot-grid span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
display: block;
|
||||
}
|
||||
|
||||
details { position: relative; }
|
||||
summary { list-style: none; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.menu-card {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background: white;
|
||||
border: 1px solid var(--gw-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.menu-card-title {
|
||||
padding: 16px 16px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gw-text-secondary);
|
||||
}
|
||||
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 12px;
|
||||
color: var(--gw-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu-item:hover { background: var(--gw-hover); }
|
||||
|
||||
.menu-item img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.account-head {
|
||||
padding: 20px 16px 8px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--gw-border);
|
||||
}
|
||||
|
||||
.account-name { font-weight: 600; }
|
||||
.account-email { color: var(--gw-text-secondary); font-size: 14px; }
|
||||
|
||||
.account-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: calc(100vh - var(--gw-header-h));
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--gw-sidebar-w);
|
||||
background: var(--gw-surface);
|
||||
padding: 8px 0;
|
||||
position: sticky;
|
||||
top: var(--gw-header-h);
|
||||
height: calc(100vh - var(--gw-header-h));
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle-input:not(:checked) ~ .app-shell .sidebar {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gw-text-secondary);
|
||||
padding: 16px 16px 4px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
height: 34px;
|
||||
border-radius: 24px;
|
||||
color: var(--gw-text-primary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid #d6d9dc;
|
||||
}
|
||||
|
||||
.sidebar-link:hover { background: var(--gw-hover); color: var(--gw-text-primary); }
|
||||
.sidebar-link.active { background: var(--gw-blue-light); color: var(--gw-blue); font-weight: 500; }
|
||||
.sidebar-link img { width: 20px; height: 20px; object-fit: contain; }
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.axia-home {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
padding: 12px 16px;
|
||||
background: #e8f0fe;
|
||||
border: 1px solid #c7dbff;
|
||||
border-radius: 8px;
|
||||
color: #174ea6;
|
||||
}
|
||||
|
||||
.form-errorlist {
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
color: #b3261e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell { display: block; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: var(--gw-header-h);
|
||||
z-index: 110;
|
||||
transform: translateX(-100%);
|
||||
width: var(--gw-sidebar-w);
|
||||
opacity: 1;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.sidebar-toggle-input:not(:checked) ~ .app-shell .sidebar {
|
||||
width: var(--gw-sidebar-w);
|
||||
transform: translateX(-100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-toggle-input:checked ~ .app-shell .sidebar {
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: var(--gw-header-h) 0 0 0;
|
||||
background: rgba(32, 33, 36, 0.35);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 105;
|
||||
}
|
||||
|
||||
.sidebar-toggle-input:checked ~ .app-shell .sidebar-backdrop {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.axia-home { padding: 16px; }
|
||||
.menu-card, .account-card { width: min(92vw, 360px); }
|
||||
.brand-text { display: none; }
|
||||
}
|
||||
|
||||
{% block page_css %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<input type="checkbox" id="sidebarToggle" class="sidebar-toggle-input">
|
||||
<script>
|
||||
(function() {
|
||||
const onReady = function() {
|
||||
const toggle = document.getElementById('sidebarToggle');
|
||||
if (!toggle) {
|
||||
return;
|
||||
}
|
||||
const storageKey = 'axia4.sidebar.open';
|
||||
const prefersDesktopOpen = window.matchMedia('(min-width: 769px)').matches;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved === 'true' || saved === 'false') {
|
||||
toggle.checked = saved === 'true';
|
||||
} else {
|
||||
toggle.checked = prefersDesktopOpen;
|
||||
}
|
||||
toggle.addEventListener('change', function() {
|
||||
localStorage.setItem(storageKey, toggle.checked ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', onReady);
|
||||
} else {
|
||||
onReady();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<header class="axia-header">
|
||||
<label for="sidebarToggle" class="sidebar-toggle" aria-label="Abrir menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"></path>
|
||||
</svg>
|
||||
</label>
|
||||
<a class="logo-area" href="{{ app_root }}">
|
||||
<img src="{% static app_icon %}" alt="{{ page_title }}" class="brand-logo">
|
||||
<span class="brand-text">{{ app_name }}</span>
|
||||
</a>
|
||||
<div class="search-bar">
|
||||
<form action="https://search.tech.eus/s/" method="get">
|
||||
<input type="text" name="q" placeholder="Busqueda global" aria-label="Buscar">
|
||||
</form>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<details class="app-menu">
|
||||
<summary class="icon-button" aria-label="Menu de aplicaciones">
|
||||
<span class="dot-grid" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="menu-card">
|
||||
<div class="menu-card-title">Aplicaciones de Axia4</div>
|
||||
<div class="menu-grid">
|
||||
{% for item in global_apps %}
|
||||
<a class="menu-item" href="{{ item.href }}">
|
||||
<img src="{% static item.icon %}" alt="">
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="account-menu">
|
||||
<summary class="avatar" aria-label="Cuenta">{% if axia_user %}{{ axia_user.initials }}{% else %}?{% endif %}</summary>
|
||||
<div class="menu-card account-card">
|
||||
<div class="account-head">
|
||||
<div class="avatar big">{% if axia_user %}{{ axia_user.initials }}{% else %}?{% endif %}</div>
|
||||
<div class="account-name">{% if axia_user %}{{ axia_user.display_name }}{% else %}Invitado{% endif %}</div>
|
||||
<div class="account-email">{% if axia_user %}{{ axia_user.email }}{% else %}Sin sesion{% endif %}</div>
|
||||
</div>
|
||||
{% if axia_user and axia_user.organizations %}
|
||||
<div style="padding: 8px 16px; border-bottom: 1px solid var(--gw-border);">
|
||||
<div style="font-size:.75rem;font-weight:600;color:#5f6368;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;">Organizacion activa</div>
|
||||
<div style="font-size:.95rem;font-weight:600;color:#1a73e8;margin-bottom:8px;">{{ axia_user.active_org_name|default:'-' }}</div>
|
||||
{% if axia_user.organizations|length > 1 %}
|
||||
<div style="font-size:.75rem;color:#5f6368;margin-bottom:4px;">Cambiar organizacion:</div>
|
||||
{% for org in axia_user.organizations %}
|
||||
{% if org.id != axia_user.active_org %}
|
||||
<form method="post" action="/switch-tenant/">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="redir" value="{{ request.get_full_path }}">
|
||||
<button type="submit" name="organization" value="{{ org.id }}" style="display:block;width:100%;text-align:left;padding:5px 8px;border:1px solid #e0e0e0;border-radius:6px;background:#f8f9fa;font-size:.85rem;cursor:pointer;margin-bottom:4px;">{{ org.name }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="account-actions">
|
||||
{% if axia_user %}
|
||||
<a href="/account/" class="btn btn-outline-dark w-100">Gestionar cuenta</a>
|
||||
<a href="/logout/" class="btn btn-outline-danger w-100">Cerrar sesion</a>
|
||||
{% else %}
|
||||
<a href="/login/?redir={{ request.path }}" class="btn btn-primary w-100">Iniciar sesion</a>
|
||||
<a href="/account/register/" class="btn btn-outline-dark w-100">Crear cuenta</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
{% if sidebar_links %}
|
||||
{% for item in sidebar_links %}
|
||||
{% if item.section %}
|
||||
<div class="sidebar-section-label">{{ item.section }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<nav class="sidebar-nav">
|
||||
{% for item in sidebar_links %}
|
||||
<a class="sidebar-link{% if item.active %} active{% endif %}" href="{{ item.href }}">
|
||||
{% if item.icon %}<img src="{{ item.icon }}" alt="">{% endif %}
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</aside>
|
||||
<label for="sidebarToggle" class="sidebar-backdrop" aria-hidden="true"></label>
|
||||
<div class="app-content">
|
||||
<main class="axia-home">
|
||||
{% if messages %}
|
||||
<div class="message-list">
|
||||
{% for message in messages %}
|
||||
<div class="message-item">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{% static 'bootstrap.bundle.min.js' %}"></script>
|
||||
<script src="{% static 'masonry.pkgd.min.js' %}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
64
django_app/templates/core/club_event.html
Normal file
64
django_app/templates/core/club_event.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block page_css %}
|
||||
.grid-item {
|
||||
width: 240px;
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
border: 3px solid black;
|
||||
border-radius: 6.5px;
|
||||
box-sizing: content-box;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
.grid-item img {
|
||||
width: 240px;
|
||||
display: block;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>{{ event_date }} - {{ event_data.title|default:'Por definir' }}</h1>
|
||||
<span>
|
||||
<a href="/club/" class="btn btn-secondary">Volver a Inicio</a>
|
||||
<a href="/club/upload/?f={{ event_ref }}" class="btn btn-primary">Subir fotos</a>
|
||||
{% if event_data.mapa.url %}
|
||||
<a class="btn btn-secondary" href="{{ event_data.mapa.url }}" target="_blank">Abrir ruta interactiva</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if event_data.mapa %}
|
||||
<h2>Ruta y estadisticas</h2>
|
||||
<p>La cartografia sigue pendiente de migracion completa, pero el evento y sus fotos ya cargan desde Django.</p>
|
||||
{% endif %}
|
||||
<h2>Fotos</h2>
|
||||
<div id="grid">
|
||||
{% for photo in photos %}
|
||||
<div class="grid-item">
|
||||
<img loading="lazy" src="{{ photo.download }}" alt="Foto de {{ photo.author }} - {{ photo.name }}">
|
||||
<div style="padding: 5px; text-align: center;">
|
||||
Subido por {{ photo.author }}<br>
|
||||
<a href="{{ photo.download }}" target="_blank" class="btn btn-secondary">Abrir</a>
|
||||
<a href="{{ photo.download }}" download class="btn btn-secondary">Descargar</a>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>No hay fotos disponibles para este evento.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
if (typeof Masonry !== 'undefined' && document.getElementById('grid')) {
|
||||
const msnry = new Masonry('#grid', {
|
||||
columnWidth: 240,
|
||||
itemSelector: '.grid-item',
|
||||
gutter: 10,
|
||||
transitionDuration: 0
|
||||
});
|
||||
setInterval(function() { msnry.layout(); }, 1000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
15
django_app/templates/core/club_index.html
Normal file
15
django_app/templates/core/club_index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<span><a href="/club/upload/" class="btn btn-secondary">+ Nuevo</a></span>
|
||||
<h2>Calendario:</h2>
|
||||
<ul>
|
||||
{% for event in events %}
|
||||
<li><a class="btn btn-secondary" href="/club/cal/?f={{ event.ref }}"><b>{{ event.date }}</b></a> - {{ event.title }}</li>
|
||||
{% empty %}
|
||||
<li>No hay eventos disponibles.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
141
django_app/templates/core/home.html
Normal file
141
django_app/templates/core/home.html
Normal file
@@ -0,0 +1,141 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block page_css %}
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin: 0 0 28px;
|
||||
background: linear-gradient(135deg, #e8f0fe 0%, #fce8ff 100%);
|
||||
padding: 48px 24px;
|
||||
border-radius: 12px;
|
||||
color: #202124;
|
||||
border: 1px solid #dadce0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 8px;
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
color: #5f6368;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hero hr {
|
||||
border-color: #dadce0;
|
||||
margin: 20px auto;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.hero h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a73e8;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hero .btn {
|
||||
border-radius: 20px;
|
||||
padding: 8px 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #5f6368;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid #dadce0;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: #bdc1c6;
|
||||
}
|
||||
|
||||
.app-card img {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
color: #202124;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
color: #5f6368;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.btn.disabled {
|
||||
color: #5f6368;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<h1>Bienvenidx a Axia4</h1>
|
||||
<p>La plataforma unificada creada por EuskadiTech.</p>
|
||||
<hr>
|
||||
<h3>Version 2.1.0</h3>
|
||||
<p>Con esta version, hacemos muchos cambios.</p>
|
||||
<br>
|
||||
<a class="btn btn-primary" href="/account/">Accede a tu cuenta</a>
|
||||
</section>
|
||||
|
||||
<p class="section-title">Aplicaciones</p>
|
||||
<div class="app-grid">
|
||||
{% for card in apps_cards %}
|
||||
<div class="app-card">
|
||||
<img src="{{ card.icon }}" alt="{{ card.title }}">
|
||||
<div class="app-title">{{ card.title }}</div>
|
||||
<div class="app-desc">{{ card.description }}</div>
|
||||
<div class="app-actions">
|
||||
{% for action in card.actions %}
|
||||
{% if action.variant == 'disabled' %}
|
||||
<span class="btn btn-outline-secondary disabled">{{ action.label }}</span>
|
||||
{% else %}
|
||||
<a href="{{ action.href }}" class="btn btn-{{ action.variant }}">{{ action.label }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
django_app/templates/core/login.html
Normal file
28
django_app/templates/core/login.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" action="?redir={{ request.GET.redir|default:'/' }}" style="max-width:500px;">
|
||||
{% csrf_token %}
|
||||
<div class="card pad" style="max-width: 500px; margin: 0 auto;">
|
||||
<h1 style="text-align:center;">Iniciar sesion en Axia4</h1>
|
||||
{% if form.non_field_errors %}
|
||||
<ul class="form-errorlist">
|
||||
{% for error in form.non_field_errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.user.id_for_label }}" class="form-label"><b>Usuario o correo electronico:</b></label>
|
||||
{{ form.user }}
|
||||
{{ form.user.errors }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.password.id_for_label }}" class="form-label"><b>Contrasena:</b></label>
|
||||
{{ form.password }}
|
||||
{{ form.password.errors }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Iniciar sesion</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
83
django_app/templates/core/module.html
Normal file
83
django_app/templates/core/module.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block page_css %}
|
||||
.tile-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.grid-item {
|
||||
margin-bottom: 10px !important;
|
||||
padding: 15px;
|
||||
width: 250px;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.grid-item img {
|
||||
margin: 0 auto;
|
||||
height: 125px;
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.tile-fallback {
|
||||
width: 125px;
|
||||
height: 125px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
font-size: 54px;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
{% if aulario %}
|
||||
<h1 class="card-title">Aulario: {{ aulario.name }}</h1>
|
||||
<span>Bienvenidx al aulario {{ aulario.name }}. Aqui podras gestionar las funcionalidades especificas de este aulario.</span>
|
||||
{% else %}
|
||||
<h1 class="card-title">{{ module_title }}</h1>
|
||||
{% if module_description %}<span>{{ module_description }}</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if tiles %}
|
||||
<div id="grid" class="tile-grid">
|
||||
{% for tile in tiles %}
|
||||
{% if tile.visible != False %}
|
||||
<a href="{{ tile.href }}" class="btn btn-{{ tile.variant|default:'primary' }} grid-item">
|
||||
{% if tile.icon %}
|
||||
<img src="{{ tile.icon }}" alt="{{ tile.label }}">
|
||||
{% else %}
|
||||
<div class="tile-fallback">□</div>
|
||||
{% endif %}
|
||||
<span>{{ tile.label }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
if (typeof Masonry !== 'undefined' && document.getElementById('grid')) {
|
||||
const msnry = new Masonry('#grid', {
|
||||
columnWidth: 250,
|
||||
itemSelector: '.grid-item',
|
||||
gutter: 10,
|
||||
transitionDuration: 0
|
||||
});
|
||||
setTimeout(function() { msnry.layout(); }, 150);
|
||||
window.addEventListener('resize', function() { msnry.layout(); }, true);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
49
django_app/templates/core/onboarding_django_admin.html
Normal file
49
django_app/templates/core/onboarding_django_admin.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad" style="max-width:640px; margin:0 auto;">
|
||||
<h1 class="card-title">Onboarding Django Admin</h1>
|
||||
<p>Crea la primera cuenta administradora de Django para acceder a <strong>/django-admin/</strong>.</p>
|
||||
<form method="post" style="display:grid; gap:12px; margin-top:12px;">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger" role="alert">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
{% for hidden in form.hidden_fields %}
|
||||
{{ hidden }}
|
||||
{% if hidden.errors %}
|
||||
<div class="text-danger small">{{ hidden.errors|striptags }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
<label for="{{ form.username.id_for_label }}" class="form-label">{{ form.username.label }}</label>
|
||||
{{ form.username }}
|
||||
{% if form.username.errors %}<div class="text-danger small">{{ form.username.errors|striptags }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.email.id_for_label }}" class="form-label">{{ form.email.label }}</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}<div class="text-danger small">{{ form.email.errors|striptags }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.password1.id_for_label }}" class="form-label">{{ form.password1.label }}</label>
|
||||
{{ form.password1 }}
|
||||
{% if form.password1.errors %}<div class="text-danger small">{{ form.password1.errors|striptags }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.password2.id_for_label }}" class="form-label">{{ form.password2.label }}</label>
|
||||
{{ form.password2 }}
|
||||
{% if form.password2.errors %}<div class="text-danger small">{{ form.password2.errors|striptags }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; margin-top:4px;">
|
||||
<button type="submit" class="btn btn-primary">Crear admin</button>
|
||||
<a href="/" class="btn btn-outline-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
32
django_app/templates/core/sysadmin_aularios.html
Normal file
32
django_app/templates/core/sysadmin_aularios.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>{{ module_title }}</h1>
|
||||
<p>Vista agrupada de aularios por organizacion.</p>
|
||||
</div>
|
||||
{% for org_id, aularios in grouped_aularios.items %}
|
||||
<div class="card pad">
|
||||
<h2 style="margin-bottom:16px;">{{ org_id }}</h2>
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:12px;">
|
||||
{% for aulario in aularios %}
|
||||
<div style="border:1px solid #dadce0; border-radius:10px; padding:16px; background:white;">
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
{% if aulario.icon_file %}
|
||||
<img src="{{ aulario.icon_file.url }}" alt="{{ aulario.name }}" style="width:48px; height:48px; object-fit:contain;">
|
||||
{% else %}
|
||||
<img src="{{ aulario.icon|default:'/static/arasaac/aulario.png' }}" alt="{{ aulario.name }}" style="width:48px; height:48px; object-fit:contain;">
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>{{ aulario.name|default:aulario.aulario_id }}</strong>
|
||||
<div style="color:#5f6368; font-size:.9rem;">{{ aulario.aulario_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="card pad">No hay aularios registrados.</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
53
django_app/templates/core/sysadmin_index.html
Normal file
53
django_app/templates/core/sysadmin_index.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block page_css %}
|
||||
.grid-item {
|
||||
margin-bottom: 10px !important;
|
||||
padding: 15px;
|
||||
width: 250px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.grid-item img {
|
||||
margin: 0 auto 10px;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>Administracion del Sistema</h1>
|
||||
<p>Bienvenido a la seccion de administracion del sistema. Aqui puedes gestionar las configuraciones y usuarios del sistema.</p>
|
||||
</div>
|
||||
<div id="grid">
|
||||
{% for tile in tiles %}
|
||||
<div class="grid-item">
|
||||
<img src="{{ tile.icon }}" alt="{{ tile.title }}">
|
||||
<b>{{ tile.title }}</b>
|
||||
<div style="margin-top:12px; display:flex; flex-direction:column; gap:6px;">
|
||||
{% for action in tile.actions %}
|
||||
<a href="{{ action.href }}" class="btn btn-primary">{{ action.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
if (typeof Masonry !== 'undefined') {
|
||||
const msnry = new Masonry('#grid', {
|
||||
columnWidth: 250,
|
||||
itemSelector: '.grid-item',
|
||||
gutter: 10,
|
||||
transitionDuration: 0
|
||||
});
|
||||
setTimeout(function() { msnry.layout(); }, 250);
|
||||
setInterval(function() { msnry.layout(); }, 1000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
48
django_app/templates/core/sysadmin_invitations.html
Normal file
48
django_app/templates/core/sysadmin_invitations.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>{{ module_title }}</h1>
|
||||
<p>Gestion de invitaciones del sistema.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pad" style="margin-bottom:16px;">
|
||||
<h2 style="margin-bottom:12px;">Crear invitacion</h2>
|
||||
<form method="post" action="/sysadmin/invitations/create/" style="display:grid; gap:12px; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr));">
|
||||
{% csrf_token %}
|
||||
<div>{{ create_form.code.label_tag }}{{ create_form.code }}</div>
|
||||
<label class="form-check" style="display:flex; gap:8px; align-items:center; padding-top:28px;">
|
||||
{{ create_form.active }}<span>Activa</span>
|
||||
</label>
|
||||
<label class="form-check" style="display:flex; gap:8px; align-items:center; padding-top:28px;">
|
||||
{{ create_form.single_use }}<span>Un solo uso</span>
|
||||
</label>
|
||||
<div style="grid-column:1/-1;"><button type="submit" class="btn btn-primary">Crear invitacion</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; gap:12px;">
|
||||
{% for invitation in invitations %}
|
||||
<div class="card pad" style="display:flex; justify-content:space-between; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong>{{ invitation.code }}</strong>
|
||||
<div style="color:#5f6368; font-size:.9rem;">Creada: {{ invitation.created_at|slice:':16' }}</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end;">
|
||||
<span class="badge rounded-pill {% if invitation.active %}text-bg-success{% else %}text-bg-secondary{% endif %}">{% if invitation.active %}Activa{% else %}Inactiva{% endif %}</span>
|
||||
<span class="badge rounded-pill text-bg-light" style="border:1px solid #d0d7de;">{% if invitation.single_use %}Un uso{% else %}Multiple{% endif %}</span>
|
||||
<form method="post" action="/sysadmin/invitations/{{ invitation.id }}/toggle/">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">{% if invitation.active %}Desactivar{% else %}Activar{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/sysadmin/invitations/{{ invitation.id }}/delete/" onsubmit="return confirm('¿Eliminar esta invitacion?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="card pad">No hay invitaciones registradas.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
51
django_app/templates/core/sysadmin_orgs.html
Normal file
51
django_app/templates/core/sysadmin_orgs.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>{{ module_title }}</h1>
|
||||
<p>Gestion de organizaciones del sistema.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pad" style="margin-bottom:16px;">
|
||||
<h2 style="margin-bottom:12px;">Crear organizacion</h2>
|
||||
<form method="post" action="/sysadmin/orgs/create/" style="display:grid; gap:12px; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr));">
|
||||
{% csrf_token %}
|
||||
<div>{{ create_form.org_id.label_tag }}{{ create_form.org_id }}</div>
|
||||
<div>{{ create_form.org_name.label_tag }}{{ create_form.org_name }}</div>
|
||||
<div style="grid-column:1/-1;"><button type="submit" class="btn btn-primary">Crear organizacion</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if edit_form and edit_org %}
|
||||
<div class="card pad" style="margin-bottom:16px; border-left:4px solid #1a73e8;">
|
||||
<h2 style="margin-bottom:12px;">Editar organizacion: {{ edit_org.org_name|default:edit_org.org_id }}</h2>
|
||||
<form method="post" action="/sysadmin/orgs/{{ edit_org.id }}/update/" style="display:grid; gap:12px; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr));">
|
||||
{% csrf_token %}
|
||||
<div>{{ edit_form.org_id.label_tag }}{{ edit_form.org_id }}</div>
|
||||
<div>{{ edit_form.org_name.label_tag }}{{ edit_form.org_name }}</div>
|
||||
<div style="grid-column:1/-1; display:flex; gap:8px;">
|
||||
<button type="submit" class="btn btn-primary">Guardar cambios</button>
|
||||
<a href="/sysadmin/orgs/" class="btn btn-outline-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid; gap:12px;">
|
||||
{% for org in organizations %}
|
||||
<div class="card pad">
|
||||
<strong>{{ org.org_name|default:org.org_id }}</strong>
|
||||
<div style="color:#5f6368;">ID: {{ org.org_id }}</div>
|
||||
<div style="margin-top:10px; display:flex; gap:8px;">
|
||||
<a href="/sysadmin/orgs/?edit={{ org.id }}" class="btn btn-sm btn-outline-primary">Editar</a>
|
||||
<form method="post" action="/sysadmin/orgs/{{ org.id }}/delete/" onsubmit="return confirm('¿Eliminar esta organizacion?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="card pad">No hay organizaciones registradas.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
76
django_app/templates/core/sysadmin_users.html
Normal file
76
django_app/templates/core/sysadmin_users.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card pad">
|
||||
<h1>{{ module_title }}</h1>
|
||||
<p>Gestion de usuarios sobre la tabla actual de Axia4.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pad" style="margin-bottom:16px;">
|
||||
<h2 style="margin-bottom:12px;">Crear usuario</h2>
|
||||
<form method="post" action="/sysadmin/users/create/" style="display:grid; gap:12px;">
|
||||
{% csrf_token %}
|
||||
<div>{{ create_form.username.label_tag }}{{ create_form.username }}</div>
|
||||
<div>{{ create_form.display_name.label_tag }}{{ create_form.display_name }}</div>
|
||||
<div>{{ create_form.email.label_tag }}{{ create_form.email }}</div>
|
||||
<div>{{ create_form.raw_password.label_tag }}{{ create_form.raw_password }}</div>
|
||||
<div>{{ create_form.permissions.label_tag }}{{ create_form.permissions }}</div>
|
||||
<div>{{ create_form.meta.label_tag }}{{ create_form.meta }}</div>
|
||||
<label class="form-check" style="display:flex; gap:8px; align-items:center;">
|
||||
{{ create_form.google_auth }}<span>Google Auth</span>
|
||||
</label>
|
||||
<div><button type="submit" class="btn btn-primary">Crear usuario</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if edit_form and edit_user %}
|
||||
<div class="card pad" style="margin-bottom:16px; border-left:4px solid #1a73e8;">
|
||||
<h2 style="margin-bottom:12px;">Editar usuario: {{ edit_user.username }}</h2>
|
||||
<form method="post" action="/sysadmin/users/{{ edit_user.id }}/update/" style="display:grid; gap:12px;">
|
||||
{% csrf_token %}
|
||||
<div>{{ edit_form.username.label_tag }}{{ edit_form.username }}</div>
|
||||
<div>{{ edit_form.display_name.label_tag }}{{ edit_form.display_name }}</div>
|
||||
<div>{{ edit_form.email.label_tag }}{{ edit_form.email }}</div>
|
||||
<div>{{ edit_form.raw_password.label_tag }}{{ edit_form.raw_password }}</div>
|
||||
<div>{{ edit_form.permissions.label_tag }}{{ edit_form.permissions }}</div>
|
||||
<div>{{ edit_form.meta.label_tag }}{{ edit_form.meta }}</div>
|
||||
<label class="form-check" style="display:flex; gap:8px; align-items:center;">
|
||||
{{ edit_form.google_auth }}<span>Google Auth</span>
|
||||
</label>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button type="submit" class="btn btn-primary">Guardar cambios</button>
|
||||
<a href="/sysadmin/users/" class="btn btn-outline-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:16px;">
|
||||
{% for user in users %}
|
||||
<div class="card pad">
|
||||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
|
||||
<div class="avatar" style="flex-shrink:0;">{{ user.initials }}</div>
|
||||
<div>
|
||||
<div style="font-weight:700;">{{ user.display_name }}</div>
|
||||
<div style="color:#5f6368; font-size:.9rem;">{{ user.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:.92rem; color:#5f6368; margin-bottom:8px;">{{ user.email }}</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:6px;">
|
||||
{% for permission in user.permissions %}
|
||||
<span class="badge rounded-pill text-bg-light" style="border:1px solid #d0d7de;">{{ permission }}</span>
|
||||
{% empty %}
|
||||
<span class="badge rounded-pill text-bg-light" style="border:1px solid #d0d7de;">Sin permisos</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||
<a href="/sysadmin/users/?edit={{ user.record.id }}" class="btn btn-sm btn-outline-primary">Editar</a>
|
||||
<form method="post" action="/sysadmin/users/{{ user.record.id }}/delete/" onsubmit="return confirm('¿Eliminar este usuario?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,16 +1,19 @@
|
||||
services:
|
||||
axia4-web:
|
||||
image: ghcr.io/axia4/axia4:main
|
||||
# Optional: Build from local Dockerfile for development
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: axia4-app
|
||||
ports:
|
||||
- "882:80"
|
||||
- "882:8000"
|
||||
working_dir: /app/django_app
|
||||
environment:
|
||||
DJANGO_DEBUG: "1"
|
||||
DJANGO_ALLOWED_HOSTS: "*"
|
||||
AXIA4_DATA_ROOT: "/DATA"
|
||||
AXIA4_DB_PATH: "/DATA/axia4.sqlite"
|
||||
volumes:
|
||||
# Mount the DATA directory for persistent storage
|
||||
- ./DATA:/DATA
|
||||
# Optional: Mount the application code for development
|
||||
- ./public_html:/var/www/html
|
||||
- ./:/app
|
||||
command: sh -c "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
Reference in New Issue
Block a user