5 Commits

261 changed files with 5948 additions and 12864 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,40 @@
from django.contrib import admin
from .models import Aulario, ComedorMenu, ComedorMenuType, Cuaderno, StudentAttachment
@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(StudentAttachment)
class StudentAttachmentAdmin(admin.ModelAdmin):
list_display = ("org_id", "aulario_id", "alumno", "created_at")
search_fields = ("org_id", "aulario_id", "alumno")
list_filter = ("org_id", "aulario_id", "created_at")
@admin.register(ComedorMenu)
class ComedorMenuAdmin(admin.ModelAdmin):
list_display = ("org_id", "aulario_id", "menu_date", "menu_type", "menu_name", "created_by")
search_fields = ("org_id", "aulario_id", "menu_name", "menu_type", "created_by")
list_filter = ("org_id", "aulario_id", "menu_date", "menu_type")
filter_horizontal = ("shared_aularios",)
@admin.register(ComedorMenuType)
class ComedorMenuTypeAdmin(admin.ModelAdmin):
list_display = ("org_id", "source_aulario_id", "type_id", "label", "color", "created_by")
search_fields = ("org_id", "source_aulario_id", "type_id", "label", "created_by")
list_filter = ("org_id", "source_aulario_id", "created_at")
filter_horizontal = ("shared_aularios",)
@admin.register(Cuaderno)
class CuadernoAdmin(admin.ModelAdmin):
list_display = ("org_id", "aulario_id", "alumno", "titulo", "created_by", "updated_at")
search_fields = ("org_id", "aulario_id", "alumno", "titulo", "created_by", "updated_by")
list_filter = ("org_id", "aulario_id", "created_at", "updated_at")

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,30 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aulatek", "0005_comedormenutype"),
]
operations = [
migrations.CreateModel(
name="Cuaderno",
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(default="")),
("titulo", models.TextField(default="")),
("contenido_encriptado", models.TextField(default="")),
("pin_hash", models.TextField(default="")),
("pin_salt", models.TextField(default="")),
("created_by", models.TextField(default="")),
("updated_by", models.TextField(default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "aulatek_cuadernos",
},
),
]

View File

@@ -0,0 +1,134 @@
import re
from pathlib import Path
from django.db import models
from django.utils import timezone
from core.models import Aulario as CoreAulario
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}"
class Cuaderno(models.Model):
org_id = models.TextField()
aulario_id = models.TextField()
alumno = models.TextField(default="")
titulo = models.TextField(default="")
contenido_encriptado = models.TextField(default="")
pin_hash = models.TextField(default="")
pin_salt = models.TextField(default="")
created_by = models.TextField(default="")
updated_by = models.TextField(default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "aulatek_cuadernos"
def __str__(self):
alumno = self.alumno or "sin-alumno"
titulo = self.titulo or "sin-titulo"
return f"{self.org_id}/{self.aulario_id}/{alumno}/{titulo}"
class Aulario(CoreAulario):
class Meta:
proxy = True
app_label = "aulatek"
verbose_name = "Aulario"
verbose_name_plural = "Aularios"

View File

@@ -0,0 +1,23 @@
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("cuadernos/", views.cuadernos, name="cuadernos"),
path("cuadernos/new/", views.cuaderno_new, name="cuaderno_new"),
path("cuadernos/<int:cuaderno_id>/", views.cuaderno_edit, name="cuaderno_edit"),
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"),
]

763
django_app/aulatek/views.py Normal file
View File

@@ -0,0 +1,763 @@
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, Cuaderno, 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 _cuadernos_permission(request: HttpRequest) -> bool:
return (
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 _cuadernos_access(request: HttpRequest):
gate, user, org_id = _alumnos_access(request)
if gate:
return gate, None, None
if not _cuadernos_permission(request):
return HttpResponseForbidden("No tienes permiso para ver cuadernos."), None, None
return None, 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 _safe_titulo(value: str) -> str:
value = (value or "").strip()
value = re.sub(r"[\x00-\x1F\x7F]", "", value)
return value[:140]
def _signature_verified(request: HttpRequest) -> bool:
return (request.POST.get("_firma_signed") or "") == "1"
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"/aulatek/cuadernos/?aulario={aulario_id}",
"label": "Cuadernos",
"icon": "/static/iconexperience/contract.png",
"variant": "dark",
"visible": _cuadernos_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,
"can_view_cuadernos": _cuadernos_permission(request),
}
)
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.")
if not _signature_verified(request):
return HttpResponseForbidden("Firma requerida para eliminar.")
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 cuadernos(request: HttpRequest):
gate, _, org_id = _cuadernos_access(request)
if gate:
return gate
aulario_id = _safe_aulario_id(request.GET.get("aulario", ""))
context = shell_context(request, "aulatek", "Cuadernos")
if not aulario_id or not org_id:
context.update(
{
"module_title": "Cuadernos",
"aulario_id": "",
"cuadernos": [],
"alumnos": [],
}
)
return render(request, "aulatek/cuadernos.html", context)
if not Aulario.objects.filter(org_id=org_id, aulario_id=aulario_id).exists():
raise Http404("Aulario no encontrado")
cuadernos_rows = Cuaderno.objects.filter(org_id=org_id, aulario_id=aulario_id).order_by("-updated_at", "-created_at")
context.update(
{
"module_title": "Cuadernos",
"aulario_id": aulario_id,
"cuadernos": cuadernos_rows,
}
)
return render(request, "aulatek/cuadernos.html", context)
def cuaderno_new(request: HttpRequest):
gate, user, org_id = _cuadernos_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":
titulo = _safe_titulo(request.POST.get("titulo", ""))
alumno = _safe_alumno_name(request.POST.get("alumno", ""))
contenido = (request.POST.get("contenido") or "").strip()
if not titulo:
messages.error(request, "El titulo es obligatorio.")
return redirect(f"/aulatek/cuadernos/new/?aulario={aulario_id}")
if not contenido:
messages.error(request, "El contenido no puede estar vacio.")
return redirect(f"/aulatek/cuadernos/new/?aulario={aulario_id}")
Cuaderno.objects.create(
org_id=org_id,
aulario_id=aulario_id,
alumno=alumno,
titulo=titulo,
contenido_encriptado=contenido,
pin_hash="",
pin_salt="",
created_by=user.username if user else "",
updated_by=user.username if user else "",
)
messages.success(request, "Cuaderno creado correctamente.")
return redirect(f"/aulatek/cuadernos/?aulario={aulario_id}")
alumnos_rows = StudentAttachment.objects.filter(org_id=org_id, aulario_id=aulario_id).order_by("alumno")
context = shell_context(request, "aulatek", "Nuevo Cuaderno")
context.update(
{
"module_title": "Nuevo Cuaderno",
"aulario_id": aulario_id,
"alumnos": [row.alumno for row in alumnos_rows],
}
)
return render(request, "aulatek/cuaderno_new.html", context)
def cuaderno_edit(request: HttpRequest, cuaderno_id: int):
gate, user, org_id = _cuadernos_access(request)
if gate:
return gate
cuaderno = Cuaderno.objects.filter(id=cuaderno_id, org_id=org_id).first()
if not cuaderno:
raise Http404("Cuaderno no encontrado")
aulario_id = cuaderno.aulario_id
decrypted_content = cuaderno.contenido_encriptado or ""
if request.method == "POST":
action = (request.POST.get("action") or "unlock").strip().lower()
if action == "unlock":
return redirect(f"/aulatek/cuadernos/{cuaderno.id}/")
if action == "update":
titulo = _safe_titulo(request.POST.get("titulo", ""))
alumno = _safe_alumno_name(request.POST.get("alumno", ""))
contenido = (request.POST.get("contenido") or "").strip()
if not titulo:
messages.error(request, "El titulo es obligatorio.")
return redirect(f"/aulatek/cuadernos/{cuaderno.id}/")
if not contenido:
messages.error(request, "El contenido no puede estar vacio.")
return redirect(f"/aulatek/cuadernos/{cuaderno.id}/")
cuaderno.titulo = titulo
cuaderno.alumno = alumno
cuaderno.pin_salt = ""
cuaderno.pin_hash = ""
cuaderno.contenido_encriptado = contenido
cuaderno.updated_by = user.username if user else ""
cuaderno.save(update_fields=["titulo", "alumno", "pin_hash", "pin_salt", "contenido_encriptado", "updated_by", "updated_at"])
messages.success(request, "Cuaderno actualizado correctamente.")
return redirect(f"/aulatek/cuadernos/{cuaderno.id}/")
if action == "delete":
if not _signature_verified(request):
messages.error(request, "Firma requerida para eliminar el cuaderno.")
return redirect(f"/aulatek/cuadernos/{cuaderno.id}/")
cuaderno.delete()
messages.success(request, "Cuaderno eliminado.")
return redirect(f"/aulatek/cuadernos/?aulario={aulario_id}")
context = shell_context(request, "aulatek", f"Cuaderno: {cuaderno.titulo}")
context.update(
{
"module_title": "Editar Cuaderno",
"cuaderno": cuaderno,
"aulario_id": aulario_id,
"decrypted_content": decrypted_content,
}
)
return render(request, "aulatek/cuaderno_edit.html", context)
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,
}
)
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":
if not _signature_verified(request):
return HttpResponseForbidden("Firma requerida para eliminar tipo.")
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.")
if not _signature_verified(request):
return HttpResponseForbidden("Firma requerida para eliminar menu.")
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 = [BASE_DIR / "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

16
django_app/club/admin.py Normal file
View File

@@ -0,0 +1,16 @@
from django.contrib import admin
from .models import ClubEvent, ClubEventPicture
@admin.register(ClubEvent)
class ClubEventAdmin(admin.ModelAdmin):
list_display = ("date_ref",)
search_fields = ("date_ref", "data")
@admin.register(ClubEventPicture)
class ClubEventPictureAdmin(admin.ModelAdmin):
list_display = ("event", "person_name", "title", "hidden", "created_at")
search_fields = ("event__date_ref", "person_name", "title", "location")
list_filter = ("hidden", "event__date_ref", "created_at")

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"

View File

@@ -0,0 +1,32 @@
from django.db import migrations, models
import club.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="ClubEventPicture",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("person_name", models.TextField(default="")),
("location", models.TextField(blank=True, default="")),
("title", models.TextField(blank=True, default="")),
("hidden", models.BooleanField(default=False)),
("original_image", models.FileField(upload_to=club.models.club_event_picture_original_upload_to)),
("compressed_image", models.FileField(blank=True, null=True, upload_to=club.models.club_event_picture_compressed_upload_to)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("event", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="pictures", to="core.clubevent")),
],
options={
"db_table": "club_event_pictures",
},
),
]

View File

144
django_app/club/models.py Normal file
View File

@@ -0,0 +1,144 @@
import re
from io import BytesIO
from pathlib import Path
from django.core.files.base import ContentFile
from django.db import models
from core.models import ClubEvent as CoreClubEvent
# Pillow
from PIL import ExifTags, Image, ImageOps
def _safe_segment(value: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]", "", (value or ""))
def club_event_picture_original_upload_to(instance, filename: str) -> str:
ext = Path(filename or "").suffix.lower() or ".bin"
date_ref = _safe_segment(getattr(instance.event, "date_ref", "")) or "event"
person = _safe_segment(instance.person_name) or "persona"
stem = _safe_segment(Path(filename or "image").stem) or "image"
return f"club/events/{date_ref}/{person}/original/{stem}{ext}"
def club_event_picture_compressed_upload_to(instance, filename: str) -> str:
date_ref = _safe_segment(getattr(instance.event, "date_ref", "")) or "event"
person = _safe_segment(instance.person_name) or "persona"
stem = _safe_segment(Path(filename or "image").stem) or "image"
return f"club/events/{date_ref}/{person}/compressed/{stem}.jpg"
def _to_degrees(value):
if isinstance(value, tuple) and len(value) == 2:
return float(value[0]) / float(value[1] or 1)
return float(value)
def _dms_to_decimal(dms, ref):
try:
deg = _to_degrees(dms[0])
minute = _to_degrees(dms[1])
second = _to_degrees(dms[2])
decimal = deg + (minute / 60.0) + (second / 3600.0)
if ref in {"S", "W"}:
decimal = -decimal
return decimal
except Exception:
return None
class ClubEventPicture(models.Model):
event = models.ForeignKey(CoreClubEvent, on_delete=models.CASCADE, related_name="pictures")
person_name = models.TextField(default="")
location = models.TextField(default="", blank=True)
title = models.TextField(default="", blank=True)
hidden = models.BooleanField(default=False)
original_image = models.FileField(upload_to=club_event_picture_original_upload_to)
compressed_image = models.FileField(upload_to=club_event_picture_compressed_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 = "club_event_pictures"
def __str__(self):
return f"{self.event.date_ref} - {self.title or self.original_image.name}"
def _extract_exif_location(self):
try:
if not self.original_image:
return ""
self.original_image.seek(0)
image = Image.open(self.original_image)
exif = image.getexif()
gps_info = {}
gps_tag_id = None
for key, value in ExifTags.TAGS.items():
if value == "GPSInfo":
gps_tag_id = key
break
if gps_tag_id is None or gps_tag_id not in exif:
return ""
raw_gps = exif.get(gps_tag_id, {})
for key, val in raw_gps.items():
tag_name = ExifTags.GPSTAGS.get(key, key)
gps_info[tag_name] = val
lat = _dms_to_decimal(gps_info.get("GPSLatitude"), gps_info.get("GPSLatitudeRef"))
lon = _dms_to_decimal(gps_info.get("GPSLongitude"), gps_info.get("GPSLongitudeRef"))
if lat is None or lon is None:
return ""
return f"{lat:.6f}, {lon:.6f}"
except Exception:
return ""
def _build_compressed_content(self):
self.original_image.seek(0)
image = Image.open(self.original_image)
image = ImageOps.exif_transpose(image)
# JPEG no soporta alpha; cuando hay transparencia la aplanamos en blanco.
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
alpha_image = image.convert("RGBA")
background = Image.new("RGB", alpha_image.size, (255, 255, 255))
background.paste(alpha_image, mask=alpha_image.getchannel("A"))
image = background
elif image.mode != "RGB":
image = image.convert("RGB")
image.thumbnail((800, 800))
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=60, optimize=True)
return ContentFile(buffer.getvalue())
def _original_image_changed(self) -> bool:
if not self.original_image:
return False
if not self.pk:
return True
previous = type(self).objects.filter(pk=self.pk).values_list("original_image", flat=True).first()
return str(previous or "") != str(self.original_image.name or "")
def save(self, *args, **kwargs):
should_refresh_assets = self._original_image_changed()
if not self.title and self.original_image:
self.title = Path(self.original_image.name).stem
if should_refresh_assets and self.original_image:
if not self.location:
self.location = self._extract_exif_location()
compressed_content = self._build_compressed_content()
compressed_name = Path(self.original_image.name).stem + ".jpg"
self.compressed_image.save(compressed_name, compressed_content, save=False)
super().save(*args, **kwargs)
class ClubEvent(CoreClubEvent):
class Meta:
proxy = True
app_label = "club"
verbose_name = "Evento"
verbose_name_plural = "Eventos"

15
django_app/club/urls.py Normal file
View File

@@ -0,0 +1,15 @@
from django.urls import path
from . import views
app_name = "club"
urlpatterns = [
path("", views.club_home, name="club_home"),
path("new-event/", views.club_new_event, name="club_new_event"),
path("cal/<str:date>/", views.club_event, name="club_event"),
path("foto/", views.club_file, name="club_file"),
path("upload/", views.club_upload, name="club_upload_generic"),
path("cal/<str:date>/upload/", views.club_upload, name="club_upload"),
]

165
django_app/club/views.py Normal file
View File

@@ -0,0 +1,165 @@
import mimetypes
import json
from datetime import date
from django.conf import settings
from django.http import FileResponse, Http404, HttpRequest, HttpResponse, JsonResponse
from django.contrib import messages
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET
from core.models import ClubEvent, Config
from core.shell import require_permission, shell_context
from .models import ClubEventPicture
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
context["can_create_event"] = bool(
request.axia_user and (request.axia_user.has_permission("club:admin") or request.axia_user.has_permission("sysadmin:access"))
)
return render(request, "club/index.html", context)
def club_new_event(request: HttpRequest):
gate = require_permission(request, "club:admin")
if gate:
return gate
if request.method == "POST":
event_date = (request.POST.get("event_date") or "").strip()
title = (request.POST.get("title") or "").strip()
try:
parsed = date.fromisoformat(event_date)
date_ref = parsed.isoformat()
except ValueError:
messages.error(request, "La fecha no es valida.")
return redirect("/club/new-event/")
if ClubEvent.objects.filter(date_ref=date_ref).exists():
messages.error(request, "Ya existe un evento con esa fecha.")
return redirect("/club/new-event/")
payload = {"title": title} if title else {}
ClubEvent.objects.create(date_ref=date_ref, data=json.dumps(payload, ensure_ascii=True))
# Compatibilidad con estructura legacy en filesystem.
(settings.AXIA4_DATA_ROOT / "club" / "IMG" / date_ref).mkdir(parents=True, exist_ok=True)
messages.success(request, "Evento creado correctamente.")
return redirect(f"/club/cal/{date_ref}/")
context = shell_context(request, "club", "Crear evento")
context.update({"module_title": "Crear evento"})
return render(request, "club/new_event.html", context)
def club_event(request: HttpRequest, date: str):
ref = (date 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 ""
photos = []
if not event:
messages.warning(request, "Evento no encontrado.")
return redirect("/club/")
for row in ClubEventPicture.objects.filter(event=event, hidden=False).order_by("-created_at"):
photos.append(
{
"author": row.person_name,
"name": row.title or row.original_image.name,
"location": row.location,
"download": row.compressed_image.url if row.compressed_image else row.original_image.url,
"original": row.original_image.url,
}
)
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, "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, date: str = ""):
event_ref = (date or request.GET.get("event") or request.POST.get("event_ref") or "").strip()
selected_event = ClubEvent.objects.filter(date_ref=event_ref).first() if event_ref else None
if not selected_event:
selected_event = ClubEvent.objects.order_by("-date_ref").first()
if request.method == "POST":
upload_pw = (Config.objects.filter(key="club_uploadpw").values_list("value", flat=True).first() or "").strip()
provided_pw = (request.POST.get("photo_password") or "").strip()
if not upload_pw:
# messages.error(request, "La contrasena de fotos no esta configurada.")
return HttpResponse("La contrasena de fotos no esta configurada. Contacta con el administrador del sistema.", status=500)
if provided_pw.upper() != upload_pw.upper():
# messages.error(request, "La contrasena para subir fotos es incorrecta.")
return HttpResponse("La contrasena para subir fotos es incorrecta.", status=403)
event_ref_post = (request.POST.get("event_ref") or "").strip()
event = ClubEvent.objects.filter(date_ref=event_ref_post).first() if event_ref_post else selected_event
if not event:
messages.error(request, "No hay eventos disponibles para subir fotos.")
return redirect("/club/")
image_files = request.FILES.getlist("original_images")
if not image_files:
fallback_image = request.FILES.get("original_image")
if fallback_image:
image_files = [fallback_image]
if not image_files:
messages.error(request, "Debes seleccionar al menos una imagen.")
return redirect(f"/club/cal/{event.date_ref}/upload/")
person_name = (request.POST.get("person_name") or "").strip()
title = (request.POST.get("title") or "").strip() or "Foto subida"
for image_file in image_files:
picture = ClubEventPicture(
event=event,
person_name=person_name,
title=title,
hidden=False,
original_image=image_file,
)
picture.save()
if len(image_files) == 1:
messages.success(request, "Foto subida correctamente.")
else:
messages.success(request, f"{len(image_files)} fotos subidas correctamente.")
return redirect(f"/club/cal/{event.date_ref}/")
events = list(ClubEvent.objects.order_by("-date_ref"))
context = shell_context(request, "club", "Subir fotos")
context.update(
{
"module_title": "Subir fotos",
"events": events,
"selected_event": selected_event,
}
)
return render(request, "club/upload.html", context)

View File

@@ -0,0 +1 @@

65
django_app/core/admin.py Normal file
View File

@@ -0,0 +1,65 @@
from django.contrib import admin
from .forms import UserAdminForm, UserOrgAdminForm
from .models import 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(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(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

272
django_app/core/forms.py Normal file
View File

@@ -0,0 +1,272 @@
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:access", PERMISSION_LABELS["aulatek:access"]),
("aulatek:alumno", PERMISSION_LABELS["aulatek:alumno"]),
("aulatek:docente", PERMISSION_LABELS["aulatek:docente"]),
("aulatek:admin", PERMISSION_LABELS["aulatek:admin"]),
("sysadmin:access", PERMISSION_LABELS["sysadmin:access"]),
]
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 @@

286
django_app/core/models.py Normal file
View File

@@ -0,0 +1,286 @@
import json
import re
import uuid
from pathlib import Path
from django.db import models
PERMISSION_LABELS = {
"aulatek:access": "AulaTek: Acceso a la app",
"aulatek:alumno": "AulaTek: Alumno",
"aulatek:docente": "AulaTek: Docente",
"aulatek:admin": "AulaTek: Admin",
"sysadmin:access": "SysAdmin: Acceso 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

75
django_app/core/shell.py Normal file
View File

@@ -0,0 +1,75 @@
from django.http import HttpRequest, HttpResponseForbidden
from django.shortcuts import redirect
from django.utils.html import format_html
from datetime import datetime
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": "/logout/", "label": "Cerrar 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":
today_str = datetime.now().strftime("%Y-%m-%d")
items = [
{"href": "/club/", "label": "Calendario de salidas"},
{"href": f"/club/cal/{today_str}/", "label": "Salida de hoy"},
{"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"),
]

130
django_app/core/views.py Normal file
View File

@@ -0,0 +1,130 @@
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": "Acceder", "variant": "primary"}
if user and user.has_permission("aulatek:access")
else {"href": "", "label": "Sin permiso", "variant": "disabled"}
],
},
{
"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,3 @@
Django>=5.1,<5.3
bcrypt>=4.1,<5
Pillow>=10,<13

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 411 B

View File

Before

Width:  |  Height:  |  Size: 353 B

After

Width:  |  Height:  |  Size: 353 B

View File

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 172 B

View File

Before

Width:  |  Height:  |  Size: 194 B

After

Width:  |  Height:  |  Size: 194 B

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

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