Compare commits
5 Commits
main
...
django-mig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174276da04 | ||
|
|
d4c17fc219 | ||
|
|
b51281c817 | ||
|
|
01f907dc5e | ||
|
|
0f368bd89f |
@@ -6,3 +6,10 @@ docker-compose.yml
|
||||
.dockerignore
|
||||
DATA
|
||||
*.md
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
venv/
|
||||
.venv/
|
||||
.env
|
||||
8
.gitignore
vendored
@@ -475,3 +475,11 @@ composer.lock
|
||||
##### Docker
|
||||
.env
|
||||
DATA/
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
venv/
|
||||
.venv/
|
||||
.env
|
||||
52
Dockerfile
@@ -1,48 +1,20 @@
|
||||
# Use FrankenPHP (Caddy + PHP)
|
||||
FROM dunglas/frankenphp
|
||||
FROM python:3.12-slim
|
||||
|
||||
# # Install system dependencies
|
||||
# RUN apt-get update && apt-get install -y \
|
||||
# zip \
|
||||
# unzip \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Configure PHP extensions
|
||||
RUN install-php-extensions gd opcache pdo pdo_sqlite
|
||||
WORKDIR /app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
COPY django_app/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY public_html/ /var/www/html/
|
||||
COPY django_app/ /app/django_app/
|
||||
COPY public_html/ /app/public_html/
|
||||
|
||||
# Copy FrankenPHP (Caddy) configuration
|
||||
COPY docker/Caddyfile /etc/frankenphp/Caddyfile
|
||||
RUN mkdir -p /DATA
|
||||
|
||||
# Create DATA directory with proper permissions
|
||||
RUN mkdir -p /DATA && \
|
||||
chown -R www-data:www-data /DATA && \
|
||||
chmod -R 755 /DATA
|
||||
WORKDIR /app/django_app
|
||||
|
||||
# Set permissions for web directory
|
||||
RUN chown -R www-data:www-data /var/www/html && \
|
||||
chmod -R 755 /var/www/html
|
||||
EXPOSE 8000
|
||||
|
||||
# Configure PHP settings
|
||||
RUN echo "session.cookie_lifetime = 604800" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "session.gc_maxlifetime = 604800" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "upload_max_filesize = 500M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "post_max_size = 500M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "date.timezone = UTC" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "display_errors = off" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.enable = 1" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.memory_consumption = 128" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.interned_strings_buffer = 8" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.max_accelerated_files = 4000" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.revalidate_freq = 60" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.fast_shutdown = 1" >> /usr/local/etc/php/conf.d/custom.ini
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"]
|
||||
|
||||
@@ -1,48 +1,20 @@
|
||||
# Use FrankenPHP (Caddy + PHP)
|
||||
FROM dunglas/frankenphp
|
||||
FROM python:3.12-slim
|
||||
|
||||
# # Install system dependencies
|
||||
# RUN apt-get update && apt-get install -y \
|
||||
# zip \
|
||||
# unzip \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Configure PHP extensions
|
||||
RUN install-php-extensions gd opcache pdo pdo_sqlite
|
||||
WORKDIR /app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
COPY django_app/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY public_html/ /var/www/html/
|
||||
COPY django_app/ /app/django_app/
|
||||
COPY public_html/ /app/public_html/
|
||||
|
||||
# Copy FrankenPHP (Caddy) configuration
|
||||
COPY docker/Caddyfile /etc/frankenphp/Caddyfile
|
||||
RUN mkdir -p /DATA
|
||||
|
||||
# Create DATA directory with proper permissions
|
||||
RUN mkdir -p /DATA && \
|
||||
chown -R www-data:www-data /DATA && \
|
||||
chmod -R 755 /DATA
|
||||
WORKDIR /app/django_app
|
||||
|
||||
# Set permissions for web directory
|
||||
RUN chown -R www-data:www-data /var/www/html && \
|
||||
chmod -R 755 /var/www/html
|
||||
EXPOSE 8000
|
||||
|
||||
# Configure PHP settings
|
||||
RUN echo "session.cookie_lifetime = 604800" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "session.gc_maxlifetime = 604800" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "upload_max_filesize = 500M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "post_max_size = 500M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "date.timezone = UTC" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "display_errors = off" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.enable = 0" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.memory_consumption = 128" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.interned_strings_buffer = 8" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.max_accelerated_files = 4000" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.revalidate_freq = 60" >> /usr/local/etc/php/conf.d/custom.ini && \
|
||||
echo "opcache.fast_shutdown = 1" >> /usr/local/etc/php/conf.d/custom.ini
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"]
|
||||
|
||||
38
django_app/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Axia4 Django
|
||||
|
||||
Migracion inicial de Axia4 a Django reutilizando:
|
||||
|
||||
- la base SQLite existente en `DATA/axia4.sqlite`
|
||||
- los recursos estaticos ya presentes en `public_html/static`
|
||||
- las rutas principales ya migradas a URLs limpias (`/login/`, `/account/`, `/aulatek/`, `/club/`, `/sysadmin/`)
|
||||
|
||||
## Arranque local
|
||||
|
||||
```bash
|
||||
cd /workspaces/Axia4/django_app
|
||||
python -m pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
## Admin de Django
|
||||
|
||||
El panel de administracion queda disponible en `/django-admin/`.
|
||||
|
||||
Si necesitas un usuario administrador de Django:
|
||||
|
||||
```bash
|
||||
cd /workspaces/Axia4/django_app
|
||||
python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
- `AXIA4_DB_PATH`: ruta a la base de datos SQLite. Si no se define, se intenta usar `/DATA/axia4.sqlite` y luego `../DATA/axia4.sqlite`.
|
||||
- `AXIA4_DATA_ROOT`: raiz de datos. Por defecto `../DATA`.
|
||||
- `DJANGO_DEBUG`: `1` o `0`.
|
||||
- `DJANGO_SECRET_KEY`: clave de Django.
|
||||
|
||||
## Alcance de esta base
|
||||
|
||||
Esta version levanta la estructura principal de la plataforma en Django con el mismo shell visual y acceso a los datos actuales. Incluye login propio, cuenta, AulaTek, Club y SysAdmin, y deja preparada la base para seguir portando modulos especificos.
|
||||
0
public_html/entreaulas/__menu.php → django_app/account/__init__.py
Executable file → Normal file
6
django_app/account/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "account"
|
||||
11
django_app/account/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "account"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.account, name="account"),
|
||||
path("register/", views.account_register, name="account_register"),
|
||||
]
|
||||
53
django_app/account/views.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import hashlib
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import render
|
||||
|
||||
from core.auth import list_user_sessions, organization_name_map, parse_user_agent
|
||||
from core.shell import require_axia_login, shell_context
|
||||
|
||||
|
||||
def account(request: HttpRequest):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
user = request.axia_user
|
||||
sessions = []
|
||||
current_token = request.session.session_key
|
||||
current_hash = current_token and hashlib.sha256(current_token.encode("utf-8")).hexdigest()
|
||||
name_map = organization_name_map()
|
||||
for session in list_user_sessions(user.username):
|
||||
ua = parse_user_agent(session.user_agent)
|
||||
sessions.append(
|
||||
{
|
||||
"session": session,
|
||||
"ua": ua,
|
||||
"label": f"{ua['browser']} - {ua['os']}",
|
||||
"current": session.session_token == current_hash,
|
||||
}
|
||||
)
|
||||
|
||||
context = shell_context(request, "account", "Mi Cuenta")
|
||||
context.update(
|
||||
{
|
||||
"organization_name": name_map.get(user.active_org, user.active_org),
|
||||
"connected_sessions": sessions,
|
||||
}
|
||||
)
|
||||
return render(request, "core/account.html", context)
|
||||
|
||||
|
||||
def account_register(request: HttpRequest):
|
||||
gate = require_axia_login(request)
|
||||
if gate:
|
||||
return gate
|
||||
context = shell_context(request, "account", "Crear cuenta")
|
||||
context.update(
|
||||
{
|
||||
"module_title": "Crear cuenta",
|
||||
"module_description": "El registro todavia no esta portado a Django.",
|
||||
"tiles": [],
|
||||
}
|
||||
)
|
||||
return render(request, "core/module.html", context)
|
||||
0
django_app/aulatek/__init__.py
Normal file
40
django_app/aulatek/admin.py
Normal 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")
|
||||
6
django_app/aulatek/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AulatekConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "aulatek"
|
||||
29
django_app/aulatek/migrations/0001_initial.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
import aulatek.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="StudentAttachment",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("org_id", models.TextField()),
|
||||
("aulario_id", models.TextField()),
|
||||
("alumno", models.TextField()),
|
||||
("data", models.JSONField(blank=True, default=dict)),
|
||||
("photo", models.FileField(blank=True, null=True, upload_to=aulatek.models.student_photo_upload_to)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "aulatek_student_attachments",
|
||||
"unique_together": {("org_id", "aulario_id", "alumno")},
|
||||
},
|
||||
),
|
||||
]
|
||||
34
django_app/aulatek/migrations/0002_comedormenu.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
import aulatek.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("aulatek", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ComedorMenu",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("org_id", models.TextField()),
|
||||
("aulario_id", models.TextField()),
|
||||
("menu_name", models.TextField()),
|
||||
("first_name", models.TextField()),
|
||||
("first_photo", models.FileField(blank=True, null=True, upload_to=aulatek.models.comedor_first_photo_upload_to)),
|
||||
("second_name", models.TextField()),
|
||||
("second_photo", models.FileField(blank=True, null=True, upload_to=aulatek.models.comedor_second_photo_upload_to)),
|
||||
("dessert_name", models.TextField()),
|
||||
("dessert_photo", models.FileField(blank=True, null=True, upload_to=aulatek.models.comedor_dessert_photo_upload_to)),
|
||||
("created_by", models.TextField(default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "aulatek_comedor_menus",
|
||||
"unique_together": {("org_id", "aulario_id", "menu_name")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
("aulatek", "0002_comedormenu"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="comedormenu",
|
||||
name="shared_aularios",
|
||||
field=models.ManyToManyField(blank=True, related_name="shared_comedor_menus", to="core.aulario"),
|
||||
),
|
||||
]
|
||||
35
django_app/aulatek/migrations/0004_comedormenu_date_type.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from django.db import migrations, models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def forward_fill_menu_type_date(apps, schema_editor):
|
||||
ComedorMenu = apps.get_model("aulatek", "ComedorMenu")
|
||||
today = timezone.localdate()
|
||||
for row in ComedorMenu.objects.all().iterator():
|
||||
row.menu_type = (row.menu_name or "Basal").strip() or "Basal"
|
||||
row.menu_date = today
|
||||
row.save(update_fields=["menu_type", "menu_date"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("aulatek", "0003_comedormenu_shared_aularios"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="comedormenu",
|
||||
name="menu_date",
|
||||
field=models.DateField(default=timezone.localdate),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="comedormenu",
|
||||
name="menu_type",
|
||||
field=models.TextField(default="Basal"),
|
||||
),
|
||||
migrations.RunPython(forward_fill_menu_type_date, migrations.RunPython.noop),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="comedormenu",
|
||||
unique_together={("org_id", "aulario_id", "menu_date", "menu_type")},
|
||||
),
|
||||
]
|
||||
70
django_app/aulatek/migrations/0005_comedormenutype.py
Normal file
@@ -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),
|
||||
]
|
||||
30
django_app/aulatek/migrations/0006_cuaderno.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
||||
0
django_app/aulatek/migrations/__init__.py
Normal file
134
django_app/aulatek/models.py
Normal 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"
|
||||
23
django_app/aulatek/urls.py
Normal 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
@@ -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")
|
||||
1
django_app/axia4_django/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
django_app/axia4_django/asgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "axia4_django.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
103
django_app/axia4_django/settings.py
Normal file
@@ -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"
|
||||
17
django_app/axia4_django/urls.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("django-admin/", admin.site.urls),
|
||||
path("", include("core.urls")),
|
||||
path("account/", include("account.urls")),
|
||||
path("aulatek/", include("aulatek.urls")),
|
||||
path("club/", include("club.urls")),
|
||||
path("sysadmin/", include("sysadmin.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
8
django_app/axia4_django/wsgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "axia4_django.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
django_app/club/__init__.py
Normal file
16
django_app/club/admin.py
Normal 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
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ClubConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "club"
|
||||
32
django_app/club/migrations/0001_initial.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
||||
0
django_app/club/migrations/__init__.py
Normal file
144
django_app/club/models.py
Normal 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
@@ -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
@@ -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)
|
||||
1
django_app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
65
django_app/core/admin.py
Normal 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
@@ -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
@@ -0,0 +1,302 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password, identify_hasher, make_password
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Organization, User, UserOrg, UserSession
|
||||
|
||||
|
||||
SESSION_USER_KEY = "axia4.user"
|
||||
SESSION_ACTIVE_ORG_KEY = "axia4.active_org"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AxiaUser:
|
||||
record: User
|
||||
active_org: str
|
||||
active_org_name: str
|
||||
organizations: list[dict]
|
||||
permissions: list[str]
|
||||
org_permissions: list[str]
|
||||
permissions_display: str
|
||||
aulas: list[str]
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
return self.record.username
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.record.display_name or self.record.username
|
||||
|
||||
@property
|
||||
def email(self) -> str:
|
||||
return self.record.email
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
parts = [part for part in self.display_name.split() if part]
|
||||
first = parts[0][0] if parts else "?"
|
||||
second = parts[1][0] if len(parts) > 1 else ""
|
||||
return (first + second).upper()
|
||||
|
||||
@property
|
||||
def google_auth(self) -> bool:
|
||||
return bool(self.record.google_auth)
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
return permission in self.permissions
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
# Alias legacy para plantillas/codigo existente.
|
||||
return self.permissions_display
|
||||
|
||||
|
||||
def _session_token(request: HttpRequest) -> Optional[str]:
|
||||
session_key = request.session.session_key
|
||||
if not session_key:
|
||||
return None
|
||||
return hashlib.sha256(session_key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _remember_hash(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _normalize_password_hash(password_hash: str) -> bytes:
|
||||
normalized = password_hash or ""
|
||||
if normalized.startswith("$2y$") or normalized.startswith("$2a$"):
|
||||
normalized = "$2b$" + normalized[4:]
|
||||
return normalized.encode("utf-8")
|
||||
|
||||
|
||||
def _is_django_password_hash(password_hash: str) -> bool:
|
||||
try:
|
||||
identify_hasher(password_hash)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_user(identifier: str) -> Optional[User]:
|
||||
identifier = (identifier or "").strip().lower()
|
||||
if not identifier:
|
||||
return None
|
||||
if "@" in identifier:
|
||||
return User.objects.filter(email__iexact=identifier).first()
|
||||
return User.objects.filter(username__iexact=identifier).first()
|
||||
|
||||
|
||||
def verify_password(user: User, raw_password: str) -> bool:
|
||||
if not user or not user.password_hash:
|
||||
return False
|
||||
if _is_django_password_hash(user.password_hash):
|
||||
return check_password(raw_password, user.password_hash)
|
||||
try:
|
||||
valid = bcrypt.checkpw(raw_password.encode("utf-8"), _normalize_password_hash(user.password_hash))
|
||||
except ValueError:
|
||||
return False
|
||||
if valid:
|
||||
user.password_hash = make_password(raw_password)
|
||||
user.save(update_fields=["password_hash"])
|
||||
return valid
|
||||
|
||||
|
||||
def authenticate(identifier: str, raw_password: str) -> Optional[User]:
|
||||
user = get_user(identifier)
|
||||
if user and verify_password(user, raw_password):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def _organization_links(user: User) -> list[UserOrg]:
|
||||
return list(
|
||||
UserOrg.objects.filter(user=user)
|
||||
.select_related("org")
|
||||
.order_by("org__org_id")
|
||||
)
|
||||
|
||||
|
||||
def build_axia_user(user: User, request: Optional[HttpRequest] = None) -> AxiaUser:
|
||||
links = _organization_links(user)
|
||||
global_permissions = [str(value) for value in user.permissions_data if isinstance(value, str)]
|
||||
organizations = [
|
||||
{
|
||||
"id": link.org.org_id,
|
||||
"name": link.org.org_name or link.org.org_id,
|
||||
"permissions": link.permissions_display,
|
||||
"permissions_values": link.permissions_values,
|
||||
"aulas": link.aulas,
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
session_active = request.session.get(SESSION_ACTIVE_ORG_KEY) if request else None
|
||||
org_ids = [org["id"] for org in organizations]
|
||||
active_org = session_active if session_active in org_ids else (org_ids[0] if org_ids else "")
|
||||
active_link = next((org for org in organizations if org["id"] == active_org), None)
|
||||
active_org_permissions = [str(value) for value in (active_link.get("permissions_values", []) if active_link else []) if isinstance(value, str)]
|
||||
merged_permissions = []
|
||||
seen = set()
|
||||
for value in global_permissions + active_org_permissions:
|
||||
if value and value not in seen:
|
||||
merged_permissions.append(value)
|
||||
seen.add(value)
|
||||
return AxiaUser(
|
||||
record=user,
|
||||
active_org=active_org,
|
||||
active_org_name=active_link["name"] if active_link else active_org,
|
||||
organizations=organizations,
|
||||
permissions=merged_permissions,
|
||||
org_permissions=active_org_permissions,
|
||||
permissions_display=active_link["permissions"] if active_link else "",
|
||||
aulas=active_link["aulas"] if active_link else [],
|
||||
)
|
||||
|
||||
|
||||
def _touch_session(token: Optional[str]) -> None:
|
||||
if token:
|
||||
UserSession.objects.filter(session_token=token).update(last_active=timezone.now())
|
||||
|
||||
|
||||
def load_user_from_session(request: HttpRequest) -> Optional[AxiaUser]:
|
||||
username = request.session.get(SESSION_USER_KEY)
|
||||
if not username:
|
||||
return None
|
||||
token = _session_token(request)
|
||||
if not token:
|
||||
return None
|
||||
valid = UserSession.objects.filter(session_token=token, username__iexact=username).exists()
|
||||
if not valid:
|
||||
request.session.flush()
|
||||
request._axia_clear_auth_cookie = True
|
||||
return None
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
request.session.flush()
|
||||
request._axia_clear_auth_cookie = True
|
||||
return None
|
||||
_touch_session(token)
|
||||
return build_axia_user(user, request)
|
||||
|
||||
|
||||
def restore_user_from_cookie(request: HttpRequest) -> Optional[AxiaUser]:
|
||||
raw_token = request.COOKIES.get(settings.AXIA4_AUTH_COOKIE)
|
||||
if not raw_token:
|
||||
return None
|
||||
remember_hash = _remember_hash(raw_token)
|
||||
session_row = UserSession.objects.filter(remember_token_hash=remember_hash).first()
|
||||
if not session_row:
|
||||
request._axia_clear_auth_cookie = True
|
||||
return None
|
||||
user = get_user(session_row.username)
|
||||
if not user:
|
||||
request._axia_clear_auth_cookie = True
|
||||
return None
|
||||
request.session.cycle_key()
|
||||
request.session[SESSION_USER_KEY] = user.username
|
||||
request.session.setdefault(SESSION_ACTIVE_ORG_KEY, request.session.get(SESSION_ACTIVE_ORG_KEY, ""))
|
||||
request.session.save()
|
||||
new_token = _session_token(request)
|
||||
if new_token:
|
||||
session_row.session_token = new_token
|
||||
session_row.last_active = timezone.now()
|
||||
session_row.save(update_fields=["session_token", "last_active"])
|
||||
return build_axia_user(user, request)
|
||||
|
||||
|
||||
def login_user(request: HttpRequest, user: User) -> AxiaUser:
|
||||
request.session.flush()
|
||||
request.session.cycle_key()
|
||||
request.session[SESSION_USER_KEY] = user.username
|
||||
axia_user = build_axia_user(user, request)
|
||||
request.session[SESSION_ACTIVE_ORG_KEY] = axia_user.active_org
|
||||
request.session.save()
|
||||
token = _session_token(request)
|
||||
raw_remember = secrets.token_hex(32)
|
||||
remember_hash = _remember_hash(raw_remember)
|
||||
if token:
|
||||
ip_address = (request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR") or "").split(",")[0].strip()
|
||||
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:512]
|
||||
UserSession.objects.update_or_create(
|
||||
session_token=token,
|
||||
defaults={
|
||||
"username": user.username.lower(),
|
||||
"ip_address": ip_address,
|
||||
"user_agent": user_agent,
|
||||
"remember_token_hash": remember_hash,
|
||||
},
|
||||
)
|
||||
request._axia_auth_cookie_value = raw_remember
|
||||
return build_axia_user(user, request)
|
||||
|
||||
|
||||
def logout_user(request: HttpRequest) -> None:
|
||||
token = _session_token(request)
|
||||
if token:
|
||||
UserSession.objects.filter(session_token=token).delete()
|
||||
request.session.flush()
|
||||
request._axia_clear_auth_cookie = True
|
||||
|
||||
|
||||
def set_active_organization(request: HttpRequest, organization_id: str) -> None:
|
||||
request.session[SESSION_ACTIVE_ORG_KEY] = organization_id
|
||||
|
||||
|
||||
def parse_user_agent(user_agent: str) -> dict:
|
||||
ua = user_agent or ""
|
||||
operating_system = "Desconocido"
|
||||
if "Android" in ua:
|
||||
operating_system = "Android"
|
||||
elif "iPhone" in ua or "iPad" in ua:
|
||||
operating_system = "iOS"
|
||||
elif "Windows" in ua:
|
||||
operating_system = "Windows"
|
||||
elif "Macintosh" in ua or "Mac OS" in ua:
|
||||
operating_system = "macOS"
|
||||
elif "Linux" in ua:
|
||||
operating_system = "Linux"
|
||||
elif "CrOS" in ua:
|
||||
operating_system = "ChromeOS"
|
||||
|
||||
browser = "Desconocido"
|
||||
if ua.startswith("Axia4Auth/"):
|
||||
browser = "Axia4 App"
|
||||
elif "Edg/" in ua:
|
||||
browser = "Edge"
|
||||
elif "OPR/" in ua or "Opera" in ua:
|
||||
browser = "Opera"
|
||||
elif "Chrome" in ua:
|
||||
browser = "Chrome"
|
||||
elif "Firefox" in ua:
|
||||
browser = "Firefox"
|
||||
elif "Safari" in ua:
|
||||
browser = "Safari"
|
||||
|
||||
icons = {
|
||||
"Chrome": "🌐",
|
||||
"Firefox": "🦊",
|
||||
"Safari": "🧭",
|
||||
"Edge": "🔷",
|
||||
"Opera": "🔴",
|
||||
"Axia4 App": "📱",
|
||||
}
|
||||
return {
|
||||
"browser": browser,
|
||||
"os": operating_system,
|
||||
"icon": icons.get(browser, "💻"),
|
||||
}
|
||||
|
||||
|
||||
def list_user_sessions(username: str):
|
||||
return UserSession.objects.filter(username__iexact=username).order_by("-last_active")
|
||||
|
||||
|
||||
def organization_name_map() -> dict:
|
||||
return {org.org_id: (org.org_name or org.org_id) for org in Organization.objects.all()}
|
||||
21
django_app/core/context_processors.py
Normal file
@@ -0,0 +1,21 @@
|
||||
def axia4(request):
|
||||
user = getattr(request, "axia_user", None)
|
||||
from .models import Config
|
||||
from django.db import OperationalError
|
||||
|
||||
try:
|
||||
config = {item.key: item.value for item in Config.objects.all()} if Config.objects.exists() else {}
|
||||
except OperationalError:
|
||||
config = {}
|
||||
return {
|
||||
"axia_user": user,
|
||||
"auth_ok": user is not None,
|
||||
"auth_config": config,
|
||||
"global_apps": [
|
||||
{"href": "/", "icon": "logo.png", "label": "Axia4"},
|
||||
{"href": "/club/", "icon": "logo-club.png", "label": "Club"},
|
||||
{"href": "/aulatek/", "icon": "logo-entreaulas.png", "label": "AulaTek"},
|
||||
{"href": "/account/", "icon": "logo-account.png", "label": "Cuenta"},
|
||||
{"href": "/sysadmin/", "icon": "logo-sysadmin.png", "label": "SysAdmin"},
|
||||
],
|
||||
}
|
||||
36
django_app/core/db_bootstrap.py
Normal file
@@ -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
@@ -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]
|
||||
33
django_app/core/middleware.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class AxiaAuthMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
from .auth import load_user_from_session, restore_user_from_cookie
|
||||
from .db_bootstrap import ensure_axia4_schema
|
||||
|
||||
ensure_axia4_schema()
|
||||
|
||||
request.axia_user = load_user_from_session(request)
|
||||
if request.axia_user is None:
|
||||
request.axia_user = restore_user_from_cookie(request)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
cookie_name = settings.AXIA4_AUTH_COOKIE
|
||||
if getattr(request, "_axia_auth_cookie_value", None):
|
||||
response.set_cookie(
|
||||
cookie_name,
|
||||
request._axia_auth_cookie_value,
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
httponly=True,
|
||||
secure=not settings.DEBUG,
|
||||
samesite="Lax",
|
||||
path="/",
|
||||
)
|
||||
if getattr(request, "_axia_clear_auth_cookie", False):
|
||||
response.delete_cookie(cookie_name, path="/", samesite="Lax")
|
||||
return response
|
||||
178
django_app/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-16 11:19
|
||||
|
||||
import core.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ClubEvent",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("date_ref", models.TextField(unique=True)),
|
||||
("data", models.TextField(default="{}")),
|
||||
],
|
||||
options={
|
||||
"db_table": "club_events",
|
||||
"managed": True,
|
||||
},
|
||||
bases=(models.Model, core.models.JsonTextMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Config",
|
||||
fields=[
|
||||
("key", models.TextField(primary_key=True, serialize=False)),
|
||||
("value", models.TextField(default="")),
|
||||
],
|
||||
options={
|
||||
"db_table": "config",
|
||||
"managed": True,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Invitation",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("code", models.TextField(unique=True)),
|
||||
("active", models.IntegerField(default=1)),
|
||||
("single_use", models.IntegerField(default=1)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "invitations",
|
||||
"managed": True,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Organization",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"org_id",
|
||||
models.TextField(
|
||||
blank=True, default=core.models.generate_short_uuid, unique=True
|
||||
),
|
||||
),
|
||||
("org_name", models.TextField(default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "organizaciones",
|
||||
"managed": True,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("username", models.TextField(unique=True)),
|
||||
("display_name", models.TextField(default="")),
|
||||
("email", models.TextField(default="")),
|
||||
("password_hash", models.TextField(default="")),
|
||||
("permissions", models.TextField(default="[]")),
|
||||
("google_auth", models.IntegerField(default=0)),
|
||||
("meta", models.TextField(default="{}")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "users",
|
||||
"managed": True,
|
||||
},
|
||||
bases=(models.Model, core.models.JsonTextMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserSession",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("session_token", models.TextField(unique=True)),
|
||||
("username", models.TextField()),
|
||||
("ip_address", models.TextField(default="")),
|
||||
("user_agent", models.TextField(default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("last_active", models.DateTimeField(auto_now=True)),
|
||||
("remember_token_hash", models.TextField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_sessions",
|
||||
"managed": True,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Aulario",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"aulario_id",
|
||||
models.TextField(default=core.models.generate_short_uuid),
|
||||
),
|
||||
("name", models.TextField(default="")),
|
||||
(
|
||||
"icon_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=core.models.aulario_icon_upload_to,
|
||||
),
|
||||
),
|
||||
("extra", models.TextField(default="{}")),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
db_column="org_id",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="core.organization",
|
||||
to_field="org_id",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "aularios",
|
||||
"managed": True,
|
||||
},
|
||||
bases=(models.Model, core.models.JsonTextMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserOrg",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
db_column="rowid", primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("role", models.TextField(default="")),
|
||||
("ea_aulas", models.TextField(default="[]")),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
db_column="org_id",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="core.organization",
|
||||
to_field="org_id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_column="user_id",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="core.user",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_orgs",
|
||||
"managed": True,
|
||||
"unique_together": {("user", "org")},
|
||||
},
|
||||
bases=(models.Model, core.models.JsonTextMixin),
|
||||
),
|
||||
]
|
||||
1
django_app/core/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
286
django_app/core/models.py
Normal 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
@@ -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,
|
||||
}
|
||||
1
django_app/core/templatetags/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
14
django_app/core/templatetags/core_extras.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def has_permission(user, permission):
|
||||
return bool(user and getattr(user, "has_permission", None) and user.has_permission(permission))
|
||||
|
||||
|
||||
@register.filter
|
||||
def startswith(value, prefix):
|
||||
return str(value).startswith(str(prefix))
|
||||
14
django_app/core/urls.py
Normal file
@@ -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
@@ -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
@@ -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()
|
||||
3
django_app/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Django>=5.1,<5.3
|
||||
bcrypt>=4.1,<5
|
||||
Pillow>=10,<13
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 411 B |
|
Before Width: | Height: | Size: 353 B After Width: | Height: | Size: 353 B |
|
Before Width: | Height: | Size: 172 B After Width: | Height: | Size: 172 B |
|
Before Width: | Height: | Size: 194 B After Width: | Height: | Size: 194 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |