diff --git a/.perm_test_apache b/.perm_test_apache
new file mode 100644
index 0000000..e69de29
diff --git a/.perm_test_exec b/.perm_test_exec
new file mode 100644
index 0000000..e69de29
diff --git a/ai/__init__.py b/ai/__init__.py
new file mode 100644
index 0000000..37a7b09
--- /dev/null
+++ b/ai/__init__.py
@@ -0,0 +1,3 @@
+"""Helpers for interacting with the Flatlogic AI proxy from Django code."""
+
+from .local_ai_api import LocalAIApi, create_response, request, decode_json_from_response # noqa: F401
diff --git a/ai/local_ai_api.py b/ai/local_ai_api.py
new file mode 100644
index 0000000..bcff732
--- /dev/null
+++ b/ai/local_ai_api.py
@@ -0,0 +1,420 @@
+"""
+LocalAIApi — lightweight Python client for the Flatlogic AI proxy.
+
+Usage (inside the Django workspace):
+
+ from ai.local_ai_api import LocalAIApi
+
+ response = LocalAIApi.create_response({
+ "input": [
+ {"role": "system", "content": "You are a helpful assistant."},
+ {"role": "user", "content": "Summarise this text in two sentences."},
+ ],
+ "text": {"format": {"type": "json_object"}},
+ })
+
+ if response.get("success"):
+ data = LocalAIApi.decode_json_from_response(response)
+ # ...
+
+# Typical successful payload (truncated):
+# {
+# "id": "resp_xxx",
+# "status": "completed",
+# "output": [
+# {"type": "reasoning", "summary": []},
+# {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]}
+# ],
+# "usage": { "input_tokens": 123, "output_tokens": 456 }
+# }
+
+The helper automatically injects the project UUID header and falls back to
+reading executor/.env if environment variables are missing.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+import ssl
+from typing import Any, Dict, Iterable, Optional
+from urllib import error as urlerror
+from urllib import request as urlrequest
+
+__all__ = [
+ "LocalAIApi",
+ "create_response",
+ "request",
+ "fetch_status",
+ "await_response",
+ "extract_text",
+ "decode_json_from_response",
+]
+
+
+_CONFIG_CACHE: Optional[Dict[str, Any]] = None
+
+
+class LocalAIApi:
+ """Static helpers mirroring the PHP implementation."""
+
+ @staticmethod
+ def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ return create_response(params, options or {})
+
+ @staticmethod
+ def request(path: Optional[str] = None, payload: Optional[Dict[str, Any]] = None,
+ options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ return request(path, payload or {}, options or {})
+
+ @staticmethod
+ def extract_text(response: Dict[str, Any]) -> str:
+ return extract_text(response)
+
+ @staticmethod
+ def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ return decode_json_from_response(response)
+
+
+def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """Signature compatible with the OpenAI Responses API."""
+ options = options or {}
+ payload = dict(params)
+
+ if not isinstance(payload.get("input"), list) or not payload["input"]:
+ return {
+ "success": False,
+ "error": "input_missing",
+ "message": 'Parameter "input" is required and must be a non-empty list.',
+ }
+
+ cfg = _config()
+ if not payload.get("model"):
+ payload["model"] = cfg["default_model"]
+
+ initial = request(options.get("path"), payload, options)
+ if not initial.get("success"):
+ return initial
+
+ data = initial.get("data")
+ if isinstance(data, dict) and "ai_request_id" in data:
+ ai_request_id = data["ai_request_id"]
+ poll_timeout = int(options.get("poll_timeout", 300))
+ poll_interval = int(options.get("poll_interval", 5))
+ return await_response(ai_request_id, {
+ "interval": poll_interval,
+ "timeout": poll_timeout,
+ "headers": options.get("headers"),
+ "timeout_per_call": options.get("timeout"),
+ })
+
+ return initial
+
+
+def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """Perform a raw request to the AI proxy."""
+ cfg = _config()
+ options = options or {}
+
+ resolved_path = path or options.get("path") or cfg["responses_path"]
+ if not resolved_path:
+ return {
+ "success": False,
+ "error": "project_id_missing",
+ "message": "PROJECT_ID is not defined; cannot resolve AI proxy endpoint.",
+ }
+
+ project_uuid = cfg["project_uuid"]
+ if not project_uuid:
+ return {
+ "success": False,
+ "error": "project_uuid_missing",
+ "message": "PROJECT_UUID is not defined; aborting AI request.",
+ }
+
+ if "project_uuid" not in payload and project_uuid:
+ payload["project_uuid"] = project_uuid
+
+ url = _build_url(resolved_path, cfg["base_url"])
+ opt_timeout = options.get("timeout")
+ timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout)
+ verify_tls = options.get("verify_tls", cfg["verify_tls"])
+
+ headers: Dict[str, str] = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ cfg["project_header"]: project_uuid,
+ }
+ extra_headers = options.get("headers")
+ if isinstance(extra_headers, Iterable):
+ for header in extra_headers:
+ if isinstance(header, str) and ":" in header:
+ name, value = header.split(":", 1)
+ headers[name.strip()] = value.strip()
+
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ return _http_request(url, "POST", body, headers, timeout, verify_tls)
+
+
+def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """Fetch status for a queued AI request."""
+ cfg = _config()
+ options = options or {}
+
+ project_uuid = cfg["project_uuid"]
+ if not project_uuid:
+ return {
+ "success": False,
+ "error": "project_uuid_missing",
+ "message": "PROJECT_UUID is not defined; aborting status check.",
+ }
+
+ status_path = _resolve_status_path(ai_request_id, cfg)
+ url = _build_url(status_path, cfg["base_url"])
+
+ opt_timeout = options.get("timeout")
+ timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout)
+ verify_tls = options.get("verify_tls", cfg["verify_tls"])
+
+ headers: Dict[str, str] = {
+ "Accept": "application/json",
+ cfg["project_header"]: project_uuid,
+ }
+ extra_headers = options.get("headers")
+ if isinstance(extra_headers, Iterable):
+ for header in extra_headers:
+ if isinstance(header, str) and ":" in header:
+ name, value = header.split(":", 1)
+ headers[name.strip()] = value.strip()
+
+ return _http_request(url, "GET", None, headers, timeout, verify_tls)
+
+
+def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """Poll status endpoint until the request is complete or timed out."""
+ options = options or {}
+ timeout = int(options.get("timeout", 300))
+ interval = int(options.get("interval", 5))
+ if interval <= 0:
+ interval = 5
+ per_call_timeout = options.get("timeout_per_call")
+
+ deadline = time.time() + max(timeout, interval)
+
+ while True:
+ status_resp = fetch_status(ai_request_id, {
+ "headers": options.get("headers"),
+ "timeout": per_call_timeout,
+ "verify_tls": options.get("verify_tls"),
+ })
+ if status_resp.get("success"):
+ data = status_resp.get("data") or {}
+ if isinstance(data, dict):
+ status_value = data.get("status")
+ if status_value == "success":
+ return {
+ "success": True,
+ "status": 200,
+ "data": data.get("response", data),
+ }
+ if status_value == "failed":
+ return {
+ "success": False,
+ "status": 500,
+ "error": str(data.get("error") or "AI request failed"),
+ "data": data,
+ }
+ else:
+ return status_resp
+
+ if time.time() >= deadline:
+ return {
+ "success": False,
+ "error": "timeout",
+ "message": "Timed out waiting for AI response.",
+ }
+ time.sleep(interval)
+
+
+def extract_text(response: Dict[str, Any]) -> str:
+ """Public helper to extract plain text from a Responses payload."""
+ return _extract_text(response)
+
+
+def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Attempt to decode JSON emitted by the model (handles markdown fences)."""
+ text = _extract_text(response)
+ if text == "":
+ return None
+
+ try:
+ decoded = json.loads(text)
+ if isinstance(decoded, dict):
+ return decoded
+ except json.JSONDecodeError:
+ pass
+
+ stripped = text.strip()
+ if stripped.startswith("```json"):
+ stripped = stripped[7:]
+ if stripped.endswith("```"):
+ stripped = stripped[:-3]
+ stripped = stripped.strip()
+ if stripped and stripped != text:
+ try:
+ decoded = json.loads(stripped)
+ if isinstance(decoded, dict):
+ return decoded
+ except json.JSONDecodeError:
+ return None
+ return None
+
+
+def _extract_text(response: Dict[str, Any]) -> str:
+ payload = response.get("data") if response.get("success") else response.get("response")
+ if isinstance(payload, dict):
+ output = payload.get("output")
+ if isinstance(output, list):
+ combined = ""
+ for item in output:
+ content = item.get("content") if isinstance(item, dict) else None
+ if isinstance(content, list):
+ for block in content:
+ if isinstance(block, dict) and block.get("type") == "output_text" and block.get("text"):
+ combined += str(block["text"])
+ if combined:
+ return combined
+ choices = payload.get("choices")
+ if isinstance(choices, list) and choices:
+ message = choices[0].get("message")
+ if isinstance(message, dict) and message.get("content"):
+ return str(message["content"])
+ if isinstance(payload, str):
+ return payload
+ return ""
+
+
+def _config() -> Dict[str, Any]:
+ global _CONFIG_CACHE # noqa: PLW0603
+ if _CONFIG_CACHE is not None:
+ return _CONFIG_CACHE
+
+ _ensure_env_loaded()
+
+ base_url = os.getenv("AI_PROXY_BASE_URL", "https://flatlogic.com")
+ project_id = os.getenv("PROJECT_ID") or None
+ responses_path = os.getenv("AI_RESPONSES_PATH")
+ if not responses_path and project_id:
+ responses_path = f"/projects/{project_id}/ai-request"
+
+ _CONFIG_CACHE = {
+ "base_url": base_url,
+ "responses_path": responses_path,
+ "project_id": project_id,
+ "project_uuid": os.getenv("PROJECT_UUID"),
+ "project_header": os.getenv("AI_PROJECT_HEADER", "project-uuid"),
+ "default_model": os.getenv("AI_DEFAULT_MODEL", "gpt-5-mini"),
+ "timeout": int(os.getenv("AI_TIMEOUT", "30")),
+ "verify_tls": os.getenv("AI_VERIFY_TLS", "true").lower() not in {"0", "false", "no"},
+ }
+ return _CONFIG_CACHE
+
+
+def _build_url(path: str, base_url: str) -> str:
+ trimmed = path.strip()
+ if trimmed.startswith("http://") or trimmed.startswith("https://"):
+ return trimmed
+ if trimmed.startswith("/"):
+ return f"{base_url}{trimmed}"
+ return f"{base_url}/{trimmed}"
+
+
+def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str:
+ base_path = (cfg.get("responses_path") or "").rstrip("/")
+ if not base_path:
+ return f"/ai-request/{ai_request_id}/status"
+ if not base_path.endswith("/ai-request"):
+ base_path = f"{base_path}/ai-request"
+ return f"{base_path}/{ai_request_id}/status"
+
+
+def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[str, str],
+ timeout: int, verify_tls: bool) -> Dict[str, Any]:
+ """
+ Shared HTTP helper for GET/POST requests.
+ """
+ req = urlrequest.Request(url, data=body, method=method.upper())
+ for name, value in headers.items():
+ req.add_header(name, value)
+
+ context = None
+ if not verify_tls:
+ context = ssl.create_default_context()
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE
+
+ try:
+ with urlrequest.urlopen(req, timeout=timeout, context=context) as resp:
+ status = resp.getcode()
+ response_body = resp.read().decode("utf-8", errors="replace")
+ except urlerror.HTTPError as exc:
+ status = exc.getcode()
+ response_body = exc.read().decode("utf-8", errors="replace")
+ except Exception as exc: # pylint: disable=broad-except
+ return {
+ "success": False,
+ "error": "request_failed",
+ "message": str(exc),
+ }
+
+ decoded = None
+ if response_body:
+ try:
+ decoded = json.loads(response_body)
+ except json.JSONDecodeError:
+ decoded = None
+
+ if 200 <= status < 300:
+ return {
+ "success": True,
+ "status": status,
+ "data": decoded if decoded is not None else response_body,
+ }
+
+ error_message = "AI proxy request failed"
+ if isinstance(decoded, dict):
+ error_message = decoded.get("error") or decoded.get("message") or error_message
+ elif response_body:
+ error_message = response_body
+
+ return {
+ "success": False,
+ "status": status,
+ "error": error_message,
+ "response": decoded if decoded is not None else response_body,
+ }
+
+
+def _ensure_env_loaded() -> None:
+ """Populate os.environ from executor/.env if variables are missing."""
+ if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"):
+ return
+
+ env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env"))
+ if not os.path.exists(env_path):
+ return
+
+ try:
+ with open(env_path, "r", encoding="utf-8") as handle:
+ for line in handle:
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
+ continue
+ key, value = stripped.split("=", 1)
+ key = key.strip()
+ value = value.strip().strip('\'"')
+ if key and not os.getenv(key):
+ os.environ[key] = value
+ except OSError:
+ pass
diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc
index 3d6501c..1e86240 100644
Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ
diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index dadfaa7..210aa89 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc
index 139db10..31e21b5 100644
Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ
diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc
index 79ce690..bdafd83 100644
Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ
diff --git a/config/settings.py b/config/settings.py
index 001b8c8..6ec8df1 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -126,9 +126,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = 'es-es'
-TIME_ZONE = 'UTC'
+TIME_ZONE = 'Europe/Madrid'
USE_I18N = True
diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc
index 3b7774e..ab38566 100644
Binary files a/core/__pycache__/__init__.cpython-311.pyc and b/core/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 5e41572..34a7528 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/apps.cpython-311.pyc b/core/__pycache__/apps.cpython-311.pyc
index 6435d92..7ad0138 100644
Binary files a/core/__pycache__/apps.cpython-311.pyc and b/core/__pycache__/apps.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
index f6e5c4e..3479328 100644
Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index 5b41fe1..e5de757 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index 4e4f113..ec30d5e 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 9d0ddd8..d550545 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 639ff3a..8bea5ae 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -1,8 +1,20 @@
from django.contrib import admin
-from .models import Ticket
+from .models import MachineModel, SparePart, ServiceIntervention
-@admin.register(Ticket)
-class TicketAdmin(admin.ModelAdmin):
- list_display = ('subject', 'status', 'priority', 'requester_email', 'created_at')
- list_filter = ('status', 'priority')
- search_fields = ('subject', 'requester_email', 'description')
+@admin.register(MachineModel)
+class MachineModelAdmin(admin.ModelAdmin):
+ list_display = ('name', 'brand')
+ search_fields = ('name', 'brand')
+
+@admin.register(SparePart)
+class SparePartAdmin(admin.ModelAdmin):
+ list_display = ('name', 'reference_code', 'stock_quantity')
+ search_fields = ('name', 'reference_code')
+ list_filter = ('stock_quantity',)
+
+@admin.register(ServiceIntervention)
+class ServiceInterventionAdmin(admin.ModelAdmin):
+ list_display = ('machine', 'technician', 'status', 'created_at')
+ list_filter = ('status', 'technician', 'created_at')
+ search_fields = ('machine__name', 'description')
+ autocomplete_fields = ('machine', 'technician')
\ No newline at end of file
diff --git a/core/context_processors.py b/core/context_processors.py
new file mode 100644
index 0000000..0bf87c3
--- /dev/null
+++ b/core/context_processors.py
@@ -0,0 +1,13 @@
+import os
+import time
+
+def project_context(request):
+ """
+ Adds project-specific environment variables to the template context globally.
+ """
+ return {
+ "project_description": os.getenv("PROJECT_DESCRIPTION", ""),
+ "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
+ # Used for cache-busting static assets
+ "deployment_timestamp": int(time.time()),
+ }
diff --git a/core/forms.py b/core/forms.py
index 7a6b83b..6c2d93f 100644
--- a/core/forms.py
+++ b/core/forms.py
@@ -1,7 +1 @@
-from django import forms
-from .models import Ticket
-
-class TicketForm(forms.ModelForm):
- class Meta:
- model = Ticket
- fields = ['subject', 'requester_email', 'priority', 'description']
+# No forms yet.
\ No newline at end of file
diff --git a/core/migrations/0002_machinemodel_serviceintervention_sparepart_and_more.py b/core/migrations/0002_machinemodel_serviceintervention_sparepart_and_more.py
new file mode 100644
index 0000000..9ff6f6f
--- /dev/null
+++ b/core/migrations/0002_machinemodel_serviceintervention_sparepart_and_more.py
@@ -0,0 +1,51 @@
+# Generated by Django 5.2.7 on 2025-11-27 07:45
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='MachineModel',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=200)),
+ ('brand', models.CharField(max_length=100)),
+ ('manual_pdf', models.FileField(blank=True, null=True, upload_to='manuals/')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ServiceIntervention',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('IN_PROGRESS', 'In Progress'), ('COMPLETED', 'Completed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)),
+ ('description', models.TextField()),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('resolved_at', models.DateTimeField(blank=True, null=True)),
+ ('machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interventions', to='core.machinemodel')),
+ ('technician', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interventions', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='SparePart',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('reference_code', models.CharField(max_length=100, unique=True)),
+ ('name', models.CharField(max_length=200)),
+ ('description', models.TextField(blank=True)),
+ ('stock_quantity', models.PositiveIntegerField(default=0)),
+ ('low_stock_threshold', models.PositiveIntegerField(default=10)),
+ ],
+ ),
+ migrations.DeleteModel(
+ name='Ticket',
+ ),
+ ]
diff --git a/core/migrations/0003_alter_machinemodel_options_and_more.py b/core/migrations/0003_alter_machinemodel_options_and_more.py
new file mode 100644
index 0000000..d5e4482
--- /dev/null
+++ b/core/migrations/0003_alter_machinemodel_options_and_more.py
@@ -0,0 +1,98 @@
+# Generated by Django 5.2.7 on 2025-11-27 07:49
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0002_machinemodel_serviceintervention_sparepart_and_more'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='machinemodel',
+ options={'verbose_name': 'Modelo de Máquina', 'verbose_name_plural': 'Modelos de Máquinas'},
+ ),
+ migrations.AlterModelOptions(
+ name='serviceintervention',
+ options={'verbose_name': 'Intervención de Servicio', 'verbose_name_plural': 'Intervenciones de Servicio'},
+ ),
+ migrations.AlterModelOptions(
+ name='sparepart',
+ options={'verbose_name': 'Repuesto', 'verbose_name_plural': 'Repuestos'},
+ ),
+ migrations.AlterField(
+ model_name='machinemodel',
+ name='brand',
+ field=models.CharField(max_length=100, verbose_name='Marca'),
+ ),
+ migrations.AlterField(
+ model_name='machinemodel',
+ name='manual_pdf',
+ field=models.FileField(blank=True, null=True, upload_to='manuals/', verbose_name='Manual (PDF)'),
+ ),
+ migrations.AlterField(
+ model_name='machinemodel',
+ name='name',
+ field=models.CharField(max_length=200, verbose_name='Nombre'),
+ ),
+ migrations.AlterField(
+ model_name='serviceintervention',
+ name='created_at',
+ field=models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación'),
+ ),
+ migrations.AlterField(
+ model_name='serviceintervention',
+ name='description',
+ field=models.TextField(verbose_name='Descripción'),
+ ),
+ migrations.AlterField(
+ model_name='serviceintervention',
+ name='machine',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interventions', to='core.machinemodel', verbose_name='Máquina'),
+ ),
+ migrations.AlterField(
+ model_name='serviceintervention',
+ name='resolved_at',
+ field=models.DateTimeField(blank=True, null=True, verbose_name='Fecha de Resolución'),
+ ),
+ migrations.AlterField(
+ model_name='serviceintervention',
+ name='status',
+ field=models.CharField(choices=[('PENDING', 'Pendiente'), ('IN_PROGRESS', 'En Progreso'), ('COMPLETED', 'Completada'), ('CANCELLED', 'Cancelada')], default='PENDING', max_length=20, verbose_name='Estado'),
+ ),
+ migrations.AlterField(
+ model_name='serviceintervention',
+ name='technician',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interventions', to=settings.AUTH_USER_MODEL, verbose_name='Técnico'),
+ ),
+ migrations.AlterField(
+ model_name='sparepart',
+ name='description',
+ field=models.TextField(blank=True, verbose_name='Descripción'),
+ ),
+ migrations.AlterField(
+ model_name='sparepart',
+ name='low_stock_threshold',
+ field=models.PositiveIntegerField(default=10, verbose_name='Umbral de Stock Bajo'),
+ ),
+ migrations.AlterField(
+ model_name='sparepart',
+ name='name',
+ field=models.CharField(max_length=200, verbose_name='Nombre'),
+ ),
+ migrations.AlterField(
+ model_name='sparepart',
+ name='reference_code',
+ field=models.CharField(max_length=100, unique=True, verbose_name='Código de Referencia'),
+ ),
+ migrations.AlterField(
+ model_name='sparepart',
+ name='stock_quantity',
+ field=models.PositiveIntegerField(default=0, verbose_name='Cantidad en Stock'),
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc
index 64d8a55..7dceafe 100644
Binary files a/core/migrations/__pycache__/0001_initial.cpython-311.pyc and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0002_machinemodel_serviceintervention_sparepart_and_more.cpython-311.pyc b/core/migrations/__pycache__/0002_machinemodel_serviceintervention_sparepart_and_more.cpython-311.pyc
new file mode 100644
index 0000000..b0f9c78
Binary files /dev/null and b/core/migrations/__pycache__/0002_machinemodel_serviceintervention_sparepart_and_more.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0003_alter_machinemodel_options_and_more.cpython-311.pyc b/core/migrations/__pycache__/0003_alter_machinemodel_options_and_more.cpython-311.pyc
new file mode 100644
index 0000000..d7711f5
Binary files /dev/null and b/core/migrations/__pycache__/0003_alter_machinemodel_options_and_more.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc
index 58b1c14..dda82a9 100644
Binary files a/core/migrations/__pycache__/__init__.cpython-311.pyc and b/core/migrations/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 78b60d1..9b991e6 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,25 +1,50 @@
from django.db import models
+from django.contrib.auth.models import User
-class Ticket(models.Model):
- STATUS_CHOICES = [
- ('open', 'Open'),
- ('in_progress', 'In Progress'),
- ('closed', 'Closed'),
- ]
+class MachineModel(models.Model):
+ name = models.CharField("Nombre", max_length=200)
+ brand = models.CharField("Marca", max_length=100)
+ manual_pdf = models.FileField("Manual (PDF)", upload_to='manuals/', blank=True, null=True)
- PRIORITY_CHOICES = [
- ('low', 'Low'),
- ('medium', 'Medium'),
- ('high', 'High'),
- ]
-
- subject = models.CharField(max_length=255)
- status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
- priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='medium')
- requester_email = models.EmailField()
- description = models.TextField()
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
+ class Meta:
+ verbose_name = "Modelo de Máquina"
+ verbose_name_plural = "Modelos de Máquinas"
def __str__(self):
- return self.subject
\ No newline at end of file
+ return f"{self.brand} {self.name}"
+
+class SparePart(models.Model):
+ reference_code = models.CharField("Código de Referencia", max_length=100, unique=True)
+ name = models.CharField("Nombre", max_length=200)
+ description = models.TextField("Descripción", blank=True)
+ stock_quantity = models.PositiveIntegerField("Cantidad en Stock", default=0)
+ low_stock_threshold = models.PositiveIntegerField("Umbral de Stock Bajo", default=10)
+
+ class Meta:
+ verbose_name = "Repuesto"
+ verbose_name_plural = "Repuestos"
+
+ def __str__(self):
+ return self.name
+
+class ServiceIntervention(models.Model):
+ STATUS_CHOICES = [
+ ('PENDING', 'Pendiente'),
+ ('IN_PROGRESS', 'En Progreso'),
+ ('COMPLETED', 'Completada'),
+ ('CANCELLED', 'Cancelada'),
+ ]
+
+ machine = models.ForeignKey(MachineModel, on_delete=models.CASCADE, related_name='interventions', verbose_name="Máquina")
+ technician = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='interventions', verbose_name="Técnico")
+ status = models.CharField("Estado", max_length=20, choices=STATUS_CHOICES, default='PENDING')
+ description = models.TextField("Descripción")
+ created_at = models.DateTimeField("Fecha de Creación", auto_now_add=True)
+ resolved_at = models.DateTimeField("Fecha de Resolución", null=True, blank=True)
+
+ class Meta:
+ verbose_name = "Intervención de Servicio"
+ verbose_name_plural = "Intervenciones de Servicio"
+
+ def __str__(self):
+ return f"Intervención para {self.machine} el {self.created_at.strftime('%d-%m-%Y')}"
\ No newline at end of file
diff --git a/core/templates/base.html b/core/templates/base.html
new file mode 100644
index 0000000..dc6f022
--- /dev/null
+++ b/core/templates/base.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ {% block title %}Knowledge Base{% endblock %}
+ {% if project_description %}
+
+
+
+ {% endif %}
+ {% if project_image_url %}
+
+
+ {% endif %}
+
+
+
+
+ {% load static %}
+
+ {% block head %}{% endblock %}
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
diff --git a/core/templates/core/article_detail.html b/core/templates/core/article_detail.html
new file mode 100644
index 0000000..8820990
--- /dev/null
+++ b/core/templates/core/article_detail.html
@@ -0,0 +1,14 @@
+{% extends 'base.html' %}
+
+{% block title %}{{ article.title }}{% endblock %}
+
+{% block content %}
+
+
{{ article.title }}
+
Published on {{ article.created_at|date:"F d, Y" }}
+
+
+ {{ article.content|safe }}
+
+
+{% endblock %}
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index f4e4991..3644ff5 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,157 +1,74 @@
-
-
+{% extends 'base.html' %}
+{% load static %}
-
-
-
- {{ project_name }}
- {% if project_description %}
-
-
-
- {% endif %}
- {% if project_image_url %}
-
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
Analyzing your requirements and generating your website…
-
- Loading…
-
-
Appwizzy AI is collecting your requirements and applying the first changes.
-
This page will refresh automatically as the plan is implemented.
-
- Runtime: Django {{ django_version }} · Python {{ python_version }} —
- UTC {{ current_time|date:"Y-m-d H:i:s" }}
-
+
+
+
+
+
+
+
{{ intervention_count }}
+
Intervenciones Totales
-
-
-
+
+
-
\ No newline at end of file
+
+
+
+
{{ pending_interventions }}
+
Intervenciones Pendientes
+
+
+
+
+
+
+
+
{{ spare_part_count }}
+
Repuestos
+
+
+
+
+
+
+
+
{{ machine_model_count }}
+
Modelos de Máquinas
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..332eff0 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,7 +1,9 @@
from django.urls import path
-from .views import home
+from django.urls import path
+
+from .views import index
urlpatterns = [
- path("", home, name="home"),
+ path("", index, name="index"),
]
diff --git a/core/views.py b/core/views.py
index c1a6d45..f306c47 100644
--- a/core/views.py
+++ b/core/views.py
@@ -3,35 +3,33 @@ import platform
from django import get_version as django_version
from django.shortcuts import render
-from django.urls import reverse_lazy
from django.utils import timezone
-from django.views.generic.edit import CreateView
-from .forms import TicketForm
-from .models import Ticket
+from .models import MachineModel, SparePart, ServiceIntervention
-def home(request):
- """Render the landing screen with loader and environment details."""
+def index(request):
+ """Render the landing screen with dashboard stats."""
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
context = {
- "project_name": "New Style",
+ "project_name": "Nespresso Repair Assistant",
"agent_brand": agent_brand,
"django_version": django_version(),
"python_version": platform.python_version(),
"current_time": now,
"host_name": host_name,
- "project_description": os.getenv("PROJECT_DESCRIPTION", ""),
+ "project_description": os.getenv("PROJECT_DESCRIPTION", "A guided tool for Nespresso machine repairs."),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
+ "intervention_count": ServiceIntervention.objects.count(),
+ "spare_part_count": SparePart.objects.count(),
+ "machine_model_count": MachineModel.objects.count(),
+ "pending_interventions": ServiceIntervention.objects.filter(status='PENDING').count(),
}
return render(request, "core/index.html", context)
-class TicketCreateView(CreateView):
- model = Ticket
- form_class = TicketForm
- template_name = "core/ticket_create.html"
- success_url = reverse_lazy("home")
+def article_detail(request):
+ return render(request, "core/article_detail.html")
\ No newline at end of file
diff --git a/static/css/custom.css b/static/css/custom.css
new file mode 100644
index 0000000..721fde9
--- /dev/null
+++ b/static/css/custom.css
@@ -0,0 +1,70 @@
+/*
+Custom Styles for Nespresso Repair Assistant
+*/
+
+:root {
+ --primary-color: #1A237E;
+ --secondary-color: #FFAB00;
+ --accent-color: #82B1FF;
+ --background-color: #F5F5F5;
+ --text-color: #212121;
+ --font-family-headings: 'Poppins', sans-serif;
+ --font-family-body: 'Roboto', sans-serif;
+}
+
+body {
+ background-color: var(--background-color);
+ font-family: var(--font-family-body);
+ color: var(--text-color);
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-family-headings);
+ font-weight: 600;
+}
+
+.hero-section {
+ background: linear-gradient(135deg, var(--primary-color), var(--accent-color));
+ color: white;
+ padding: 4rem 2rem;
+ border-radius: 0 0 2rem 2rem;
+}
+
+.hero-section h1 {
+ font-weight: 700;
+}
+
+.stat-card {
+ background-color: white;
+ border: none;
+ border-radius: 1rem;
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.05);
+ transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
+}
+
+.stat-card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 0.75rem 1.5rem rgba(0, 0, 0, 0.1);
+}
+
+.stat-card .card-body {
+ padding: 2rem;
+}
+
+.stat-card .stat-number {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--primary-color);
+}
+
+.stat-card .card-title {
+ font-size: 1.1rem;
+ font-weight: 500;
+ color: #6c757d;
+}
+
+.navbar-brand {
+ font-family: var(--font-family-headings);
+ font-weight: 700;
+ color: var(--primary-color) !important;
+}
\ No newline at end of file
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 108056f..721fde9 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -1,21 +1,70 @@
+/*
+Custom Styles for Nespresso Repair Assistant
+*/
:root {
- --bg-color-start: #6a11cb;
- --bg-color-end: #2575fc;
- --text-color: #ffffff;
- --card-bg-color: rgba(255, 255, 255, 0.01);
- --card-border-color: rgba(255, 255, 255, 0.1);
+ --primary-color: #1A237E;
+ --secondary-color: #FFAB00;
+ --accent-color: #82B1FF;
+ --background-color: #F5F5F5;
+ --text-color: #212121;
+ --font-family-headings: 'Poppins', sans-serif;
+ --font-family-body: 'Roboto', sans-serif;
}
+
body {
- margin: 0;
- font-family: 'Inter', sans-serif;
- background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
+ background-color: var(--background-color);
+ font-family: var(--font-family-body);
color: var(--text-color);
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 100vh;
- text-align: center;
- overflow: hidden;
- position: relative;
}
+
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-family-headings);
+ font-weight: 600;
+}
+
+.hero-section {
+ background: linear-gradient(135deg, var(--primary-color), var(--accent-color));
+ color: white;
+ padding: 4rem 2rem;
+ border-radius: 0 0 2rem 2rem;
+}
+
+.hero-section h1 {
+ font-weight: 700;
+}
+
+.stat-card {
+ background-color: white;
+ border: none;
+ border-radius: 1rem;
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.05);
+ transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
+}
+
+.stat-card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 0.75rem 1.5rem rgba(0, 0, 0, 0.1);
+}
+
+.stat-card .card-body {
+ padding: 2rem;
+}
+
+.stat-card .stat-number {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--primary-color);
+}
+
+.stat-card .card-title {
+ font-size: 1.1rem;
+ font-weight: 500;
+ color: #6c757d;
+}
+
+.navbar-brand {
+ font-family: var(--font-family-headings);
+ font-weight: 700;
+ color: var(--primary-color) !important;
+}
\ No newline at end of file