Migration to django

This commit is contained in:
Naiel
2026-03-16 12:43:36 +00:00
parent d82c100e19
commit 0f368bd89f
71 changed files with 4933 additions and 86 deletions

View File

@@ -6,3 +6,10 @@ docker-compose.yml
.dockerignore
DATA
*.md
__pycache__/
*.pyc
*.pyo
*.pyd
venv/
.venv/
.env

8
.gitignore vendored
View File

@@ -475,3 +475,11 @@ composer.lock
##### Docker
.env
DATA/
__pycache__/
*.pyc
*.pyo
*.pyd
venv/
.venv/
.env

View File

@@ -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"]

View File

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

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "account"

View 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"),
]

View 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)

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AulatekConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "aulatek"

View 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")},
},
),
]

View 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")},
},
),
]

View File

@@ -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"),
),
]

View 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")},
),
]

View 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),
]

View 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}"

View 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
View 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")

View File

@@ -0,0 +1 @@

View 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()

View 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"

View 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)

View 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()

View File

6
django_app/club/apps.py Normal file
View 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
View 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
View 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)

View File

@@ -0,0 +1 @@

78
django_app/core/admin.py Normal file
View 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
View 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
View 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()}

View 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"},
],
}

View 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
View 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]

View 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

View 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),
),
]

View File

@@ -0,0 +1 @@

284
django_app/core/models.py Normal file
View 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
View 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,
}

View File

@@ -0,0 +1 @@

View 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
View 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
View 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
View 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()

View File

@@ -0,0 +1,2 @@
Django>=5.1,<5.3
bcrypt>=4.1,<5

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SysadminConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "sysadmin"

View 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"),
]

View 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)

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 }}&amp;date={{ prev_date|date:'Y-m-d' }}&amp;menu={{ selected_menu_type|urlencode }}">&larr;</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 }}&amp;date={{ next_date|date:'Y-m-d' }}&amp;menu={{ selected_menu_type|urlencode }}">&rarr;</a>
</div>
</div>
<div class="control-row" style="justify-content: right;">
{% for type in menu_types %}
<a href="/aulatek/comedor/?aulario={{ aulario_id }}&amp;date={{ selected_date|date:'Y-m-d' }}&amp;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 %}

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

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