From d11d543bdc8be729da75915134a4cc1bd12daeb4 Mon Sep 17 00:00:00 2001 From: Kevin Rosales Date: Mon, 22 Sep 2025 18:43:29 -0600 Subject: [PATCH] Mudanza de repo --- .dockerignore | 15 + .env.example | 20 + .gitattributes | 1 + .gitignore | 181 ++++++ Dockerfile | 20 + Dockerfile.prod | 22 + api/__init__.py | 0 api/cards/__init__.py | 0 api/cards/admin.py | 3 + api/cards/apps.py | 6 + api/cards/models.py | 3 + api/cards/tests.py | 79 +++ api/cards/urls.py | 21 + api/cards/views.py | 457 ++++++++++++++ api/cuser/__init__.py | 0 api/cuser/admin.py | 51 ++ api/cuser/apps.py | 6 + api/cuser/jwt_cookie_views.py | 45 ++ api/cuser/migrations/0001_initial.py | 51 ++ .../migrations/0002_alter_customuser_rfc.py | 18 + .../migrations/0003_alter_customuser_rfc.py | 18 + api/cuser/migrations/__init__.py | 0 api/cuser/models.py | 23 + api/cuser/password_reset_utils.py | 24 + api/cuser/serializers.py | 29 + api/cuser/tests.py | 91 +++ api/cuser/urls.py | 18 + api/cuser/utils.py | 28 + api/cuser/views.py | 275 ++++++++ api/customs/__init__.py | 0 api/customs/admin.py | 65 ++ api/customs/apps.py | 9 + api/customs/migrations/0001_initial.py | 224 +++++++ ...remove_agenteaduanal_id_aduana_and_more.py | 36 ++ api/customs/migrations/0003_cove.py | 32 + ...pedimento_app_alter_pedimento_pedimento.py | 24 + .../0005_remove_pedimento_pedimento_app.py | 17 + .../0006_pedimento_pedimento_app.py | 19 + api/customs/migrations/0007_regimen.py | 27 + .../migrations/0008_regimen_catalogo.py | 139 +++++ api/customs/migrations/0009_importador.py | 31 + .../migrations/0009b_poblar_importadores.py | 21 + .../0010_alter_pedimento_contribuyente.py | 19 + api/customs/migrations/__init__.py | 0 api/customs/models.py | 187 ++++++ api/customs/serializers.py | 96 +++ api/customs/signals/__init__.py | 0 api/customs/signals/procesamiento.py | 59 ++ api/customs/tasks/__init__.py | 2 + api/customs/tasks/auditoria.py | 78 +++ api/customs/tasks/internal_services.py | 220 +++++++ api/customs/tasks/microservice.py | 213 +++++++ api/customs/tests.py | 77 +++ api/customs/urls.py | 34 + api/customs/views.py | 490 +++++++++++++++ api/datastage/__init__.py | 0 api/datastage/admin.py | 44 ++ api/datastage/apps.py | 7 + api/datastage/migrations/0001_initial.py | 33 + ...emove_datastage_almacenamiento_and_more.py | 32 + .../0003_alter_datastage_organizacion.py | 20 + ...tro501_registro502_registro503_and_more.py | 588 ++++++++++++++++++ .../0005_registro701_registro702.py | 63 ++ ...istro520_municpio_destinatario_and_more.py | 22 + .../0007_alter_datastage_archivo_and_more.py | 23 + .../0008_alter_datastage_archivo_and_more.py | 25 + ...ter_registro501_tipo_operacion_and_more.py | 23 + ...er_registro501_clave_documento_and_more.py | 33 + ...er_registro502_fecha_pago_real_and_more.py | 28 + api/datastage/migrations/__init__.py | 0 api/datastage/models.py | 571 +++++++++++++++++ api/datastage/serializer.py | 12 + api/datastage/tasks.py | 276 ++++++++ api/datastage/tests.py | 85 +++ api/datastage/urls.py | 12 + api/datastage/views.py | 140 +++++ api/licence/__init__.py | 0 api/licence/admin.py | 10 + api/licence/apps.py | 6 + api/licence/migrations/0001_initial.py | 28 + api/licence/migrations/__init__.py | 0 api/licence/models.py | 20 + api/licence/serializers.py | 9 + api/licence/tests.py | 53 ++ api/licence/urls.py | 22 + api/licence/views.py | 61 ++ api/logger/__init__.py | 0 api/logger/admin.py | 149 +++++ api/logger/apps.py | 6 + api/logger/middleware.py | 91 +++ api/logger/migrations/0001_initial.py | 73 +++ api/logger/migrations/__init__.py | 0 api/logger/mixins.py | 103 +++ api/logger/models.py | 89 +++ api/logger/serializers.py | 29 + api/logger/tests.py | 3 + api/logger/urls.py | 12 + api/logger/utils.py | 98 +++ api/logger/views.py | 92 +++ api/notificaciones/__init__.py | 0 api/notificaciones/admin.py | 20 + api/notificaciones/apps.py | 11 + api/notificaciones/consumers.py | 0 api/notificaciones/migrations/0001_initial.py | 49 ++ api/notificaciones/migrations/__init__.py | 0 api/notificaciones/models.py | 35 ++ api/notificaciones/routing.py | 0 api/notificaciones/serializers.py | 25 + api/notificaciones/signals/__init__.py | 0 api/notificaciones/signals/notificaciones.py | 34 + api/notificaciones/tests.py | 77 +++ api/notificaciones/urls.py | 13 + api/notificaciones/views.py | 50 ++ api/organization/__init__.py | 0 api/organization/admin.py | 18 + api/organization/apps.py | 10 + api/organization/migrations/0001_initial.py | 60 ++ ...remove_organizacion_membretado_and_more.py | 36 ++ api/organization/migrations/__init__.py | 0 api/organization/models.py | 93 +++ api/organization/serializers.py | 15 + api/organization/signals.py | 8 + api/organization/tests.py | 64 ++ api/organization/urls.py | 25 + api/organization/views.py | 122 ++++ api/record/__init__.py | 0 api/record/admin.py | 29 + api/record/apps.py | 6 + api/record/migrations/0001_initial.py | 52 ++ .../migrations/0002_fuente_document_fuente.py | 33 + api/record/migrations/__init__.py | 0 api/record/models.py | 97 +++ api/record/serializers.py | 39 ++ api/record/tests.py | 97 +++ api/record/urls.py | 27 + api/record/views.py | 266 ++++++++ api/reports/__init__.py | 0 api/reports/admin.py | 3 + api/reports/apps.py | 6 + api/reports/models.py | 3 + api/reports/serializers.py | 7 + api/reports/tests.py | 3 + api/reports/urls.py | 6 + api/reports/views.py | 93 +++ api/vucem/__init__.py | 0 api/vucem/admin.py | 20 + api/vucem/apps.py | 6 + api/vucem/migrations/0001_initial.py | 42 ++ api/vucem/migrations/0002_vucem_user.py | 21 + .../migrations/0003_remove_vucem_user.py | 17 + .../migrations/0004_usuarioimportador.py | 33 + .../0005_usuarioimportador_organizacion.py | 20 + .../migrations/0006_vucem_cer_vucem_key.py | 25 + api/vucem/migrations/0007_vucem_efirma.py | 19 + .../migrations/0008_alter_vucem_efirma.py | 18 + .../0009_remove_usuarioimportador_user.py | 17 + ...ortador_credencialesimportador_and_more.py | 26 + .../0011_alter_credencialesimportador_rfc.py | 20 + api/vucem/migrations/__init__.py | 0 api/vucem/models.py | 60 ++ api/vucem/serializers.py | 40 ++ api/vucem/tests.py | 3 + api/vucem/urls.py | 15 + api/vucem/views.py | 182 ++++++ config/__init__.py | 3 + config/asgi.py | 16 + config/celery.py | 8 + config/settings.py | 352 +++++++++++ config/urls.py | 59 ++ config/wsgi.py | 16 + core/__init__.py | 0 core/dashboard.py | 17 + core/permissions.py | 100 +++ core/swagger.py | 8 + core/swagger_auth.py | 55 ++ core/utils.py | 310 +++++++++ .../EFC/DataStage Dataflow (2).drawio | 278 +++++++++ .../EFC/DataStage Dataflow.drawio (2).svg | 4 + ...Carga de datos de SCAII_Winsaii (1).drawio | 52 ++ ...a de datos de SCAII_Winsaii.drawio (1).svg | 4 + docs/db/database_schema.dbml | 176 ++++++ manage.py | 22 + mixins/__init__.py | 0 mixins/filtrado_organizacion.py | 142 +++++ registros.json | 152 +++++ requirements.txt | 68 ++ script/__init__.py | 0 script/script.py | 174 ++++++ ssi.md | 50 ++ supervisord.conf | 89 +++ templates/admin/dashboard_admin.html | 24 + templates/email/activation_email.html | 44 ++ templates/email/password_reset_email.html | 44 ++ 193 files changed, 10998 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.prod create mode 100644 api/__init__.py create mode 100644 api/cards/__init__.py create mode 100644 api/cards/admin.py create mode 100644 api/cards/apps.py create mode 100644 api/cards/models.py create mode 100644 api/cards/tests.py create mode 100644 api/cards/urls.py create mode 100644 api/cards/views.py create mode 100644 api/cuser/__init__.py create mode 100644 api/cuser/admin.py create mode 100644 api/cuser/apps.py create mode 100644 api/cuser/jwt_cookie_views.py create mode 100644 api/cuser/migrations/0001_initial.py create mode 100644 api/cuser/migrations/0002_alter_customuser_rfc.py create mode 100644 api/cuser/migrations/0003_alter_customuser_rfc.py create mode 100644 api/cuser/migrations/__init__.py create mode 100644 api/cuser/models.py create mode 100644 api/cuser/password_reset_utils.py create mode 100644 api/cuser/serializers.py create mode 100644 api/cuser/tests.py create mode 100644 api/cuser/urls.py create mode 100644 api/cuser/utils.py create mode 100644 api/cuser/views.py create mode 100644 api/customs/__init__.py create mode 100644 api/customs/admin.py create mode 100644 api/customs/apps.py create mode 100644 api/customs/migrations/0001_initial.py create mode 100644 api/customs/migrations/0002_remove_agenteaduanal_id_aduana_and_more.py create mode 100644 api/customs/migrations/0003_cove.py create mode 100644 api/customs/migrations/0004_pedimento_pedimento_app_alter_pedimento_pedimento.py create mode 100644 api/customs/migrations/0005_remove_pedimento_pedimento_app.py create mode 100644 api/customs/migrations/0006_pedimento_pedimento_app.py create mode 100644 api/customs/migrations/0007_regimen.py create mode 100644 api/customs/migrations/0008_regimen_catalogo.py create mode 100644 api/customs/migrations/0009_importador.py create mode 100644 api/customs/migrations/0009b_poblar_importadores.py create mode 100644 api/customs/migrations/0010_alter_pedimento_contribuyente.py create mode 100644 api/customs/migrations/__init__.py create mode 100644 api/customs/models.py create mode 100644 api/customs/serializers.py create mode 100644 api/customs/signals/__init__.py create mode 100644 api/customs/signals/procesamiento.py create mode 100644 api/customs/tasks/__init__.py create mode 100644 api/customs/tasks/auditoria.py create mode 100644 api/customs/tasks/internal_services.py create mode 100644 api/customs/tasks/microservice.py create mode 100644 api/customs/tests.py create mode 100644 api/customs/urls.py create mode 100644 api/customs/views.py create mode 100644 api/datastage/__init__.py create mode 100644 api/datastage/admin.py create mode 100644 api/datastage/apps.py create mode 100644 api/datastage/migrations/0001_initial.py create mode 100644 api/datastage/migrations/0002_remove_datastage_almacenamiento_and_more.py create mode 100644 api/datastage/migrations/0003_alter_datastage_organizacion.py create mode 100644 api/datastage/migrations/0004_registro500_registro501_registro502_registro503_and_more.py create mode 100644 api/datastage/migrations/0005_registro701_registro702.py create mode 100644 api/datastage/migrations/0006_rename_municipio_destinatario_registro520_municpio_destinatario_and_more.py create mode 100644 api/datastage/migrations/0007_alter_datastage_archivo_and_more.py create mode 100644 api/datastage/migrations/0008_alter_datastage_archivo_and_more.py create mode 100644 api/datastage/migrations/0009_alter_registro501_tipo_operacion_and_more.py create mode 100644 api/datastage/migrations/0010_alter_registro501_clave_documento_and_more.py create mode 100644 api/datastage/migrations/0011_alter_registro502_fecha_pago_real_and_more.py create mode 100644 api/datastage/migrations/__init__.py create mode 100644 api/datastage/models.py create mode 100644 api/datastage/serializer.py create mode 100644 api/datastage/tasks.py create mode 100644 api/datastage/tests.py create mode 100644 api/datastage/urls.py create mode 100644 api/datastage/views.py create mode 100644 api/licence/__init__.py create mode 100644 api/licence/admin.py create mode 100644 api/licence/apps.py create mode 100644 api/licence/migrations/0001_initial.py create mode 100644 api/licence/migrations/__init__.py create mode 100644 api/licence/models.py create mode 100644 api/licence/serializers.py create mode 100644 api/licence/tests.py create mode 100644 api/licence/urls.py create mode 100644 api/licence/views.py create mode 100644 api/logger/__init__.py create mode 100644 api/logger/admin.py create mode 100644 api/logger/apps.py create mode 100644 api/logger/middleware.py create mode 100644 api/logger/migrations/0001_initial.py create mode 100644 api/logger/migrations/__init__.py create mode 100644 api/logger/mixins.py create mode 100644 api/logger/models.py create mode 100644 api/logger/serializers.py create mode 100644 api/logger/tests.py create mode 100644 api/logger/urls.py create mode 100644 api/logger/utils.py create mode 100644 api/logger/views.py create mode 100644 api/notificaciones/__init__.py create mode 100644 api/notificaciones/admin.py create mode 100644 api/notificaciones/apps.py create mode 100644 api/notificaciones/consumers.py create mode 100644 api/notificaciones/migrations/0001_initial.py create mode 100644 api/notificaciones/migrations/__init__.py create mode 100644 api/notificaciones/models.py create mode 100644 api/notificaciones/routing.py create mode 100644 api/notificaciones/serializers.py create mode 100644 api/notificaciones/signals/__init__.py create mode 100644 api/notificaciones/signals/notificaciones.py create mode 100644 api/notificaciones/tests.py create mode 100644 api/notificaciones/urls.py create mode 100644 api/notificaciones/views.py create mode 100644 api/organization/__init__.py create mode 100644 api/organization/admin.py create mode 100644 api/organization/apps.py create mode 100644 api/organization/migrations/0001_initial.py create mode 100644 api/organization/migrations/0002_remove_organizacion_membretado_and_more.py create mode 100644 api/organization/migrations/__init__.py create mode 100644 api/organization/models.py create mode 100644 api/organization/serializers.py create mode 100644 api/organization/signals.py create mode 100644 api/organization/tests.py create mode 100644 api/organization/urls.py create mode 100644 api/organization/views.py create mode 100644 api/record/__init__.py create mode 100644 api/record/admin.py create mode 100644 api/record/apps.py create mode 100644 api/record/migrations/0001_initial.py create mode 100644 api/record/migrations/0002_fuente_document_fuente.py create mode 100644 api/record/migrations/__init__.py create mode 100644 api/record/models.py create mode 100644 api/record/serializers.py create mode 100644 api/record/tests.py create mode 100644 api/record/urls.py create mode 100644 api/record/views.py create mode 100644 api/reports/__init__.py create mode 100644 api/reports/admin.py create mode 100644 api/reports/apps.py create mode 100644 api/reports/models.py create mode 100644 api/reports/serializers.py create mode 100644 api/reports/tests.py create mode 100644 api/reports/urls.py create mode 100644 api/reports/views.py create mode 100644 api/vucem/__init__.py create mode 100644 api/vucem/admin.py create mode 100644 api/vucem/apps.py create mode 100644 api/vucem/migrations/0001_initial.py create mode 100644 api/vucem/migrations/0002_vucem_user.py create mode 100644 api/vucem/migrations/0003_remove_vucem_user.py create mode 100644 api/vucem/migrations/0004_usuarioimportador.py create mode 100644 api/vucem/migrations/0005_usuarioimportador_organizacion.py create mode 100644 api/vucem/migrations/0006_vucem_cer_vucem_key.py create mode 100644 api/vucem/migrations/0007_vucem_efirma.py create mode 100644 api/vucem/migrations/0008_alter_vucem_efirma.py create mode 100644 api/vucem/migrations/0009_remove_usuarioimportador_user.py create mode 100644 api/vucem/migrations/0010_rename_usuarioimportador_credencialesimportador_and_more.py create mode 100644 api/vucem/migrations/0011_alter_credencialesimportador_rfc.py create mode 100644 api/vucem/migrations/__init__.py create mode 100644 api/vucem/models.py create mode 100644 api/vucem/serializers.py create mode 100644 api/vucem/tests.py create mode 100644 api/vucem/urls.py create mode 100644 api/vucem/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/celery.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/dashboard.py create mode 100644 core/permissions.py create mode 100644 core/swagger.py create mode 100644 core/swagger_auth.py create mode 100644 core/utils.py create mode 100644 docs/Flujo de datos/EFC/DataStage Dataflow (2).drawio create mode 100644 docs/Flujo de datos/EFC/DataStage Dataflow.drawio (2).svg create mode 100644 docs/Flujo de datos/SCAII/Carga de datos de SCAII_Winsaii (1).drawio create mode 100644 docs/Flujo de datos/SCAII/Carga de datos de SCAII_Winsaii.drawio (1).svg create mode 100644 docs/db/database_schema.dbml create mode 100755 manage.py create mode 100644 mixins/__init__.py create mode 100644 mixins/filtrado_organizacion.py create mode 100644 registros.json create mode 100644 requirements.txt create mode 100644 script/__init__.py create mode 100644 script/script.py create mode 100644 ssi.md create mode 100644 supervisord.conf create mode 100644 templates/admin/dashboard_admin.html create mode 100644 templates/email/activation_email.html create mode 100644 templates/email/password_reset_email.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1212a45 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +__pycache__/ +*.pyc +*.pyo +*.pyd +media/ +static/ +logs/ +*.log +.env +.DS_Store +*.sqlite3 +celerybeat-schedule +supervisord.log +supervisord.pid diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef9bec3 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +SITE_URL=http://localhost:5173/ +SECRET_KEY=django-insecure-*b7u3902e^k2&i=pg4hh0*^t=s%)$h9#6u0zjt64d6_ng#c*ei +DEBUG=True + + +ALLOWED_HOSTS=localhost,host.docker.internal,127.0.0.1,192.168.1.195,192.168.1.195:8000,192.168.1.195:8001 +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8000,http://localhost:8001,http://192.168.1.195:5173,http://192.168.1.195:8001,http://192.168.1.195 + +DB_PASSWORD=Soluciones01 +DB_NAME=postgres +DB_USER=postgres +DB_PORT=5432 +DB_HOST=postgres_dev + +EMAIL_HOST_USER=noreply@aduanasoft.com.mx +EMAIL_HOST_PASSWORD=N036p7y! +EMAIL_PORT=587 +EMAIL_HOST=secure.emailsrvr.com + +SERVICE_API_URL=http://localhost:8001/api/v1 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..486a232 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.zip filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a386bea --- /dev/null +++ b/.gitignore @@ -0,0 +1,181 @@ +# Created by https://www.toptal.com/developers/gitignore/api/django +# Edit at https://www.toptal.com/developers/gitignore?templates=django + +### Django ### +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media +.env +*.log.* +*.pid +*.err +media.* +static +staticfiles +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# End of https://www.toptal.com/developers/gitignore/api/django + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22017e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ + +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +RUN pip install flower + + +COPY . . + + + +EXPOSE 8000 +EXPOSE 5555 + + +# El comando se define en docker-compose para cada servicio (backend, worker, beat, flower) diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..21f0b35 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + supervisor \ + && rm -rf /var/lib/apt/lists/* + +# Copiar e instalar dependencias de Python +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn + +# Copiar código de la aplicación +COPY . . + + +## Ya no se usa supervisord, ni se copia su configuración + +EXPOSE 8000 + +# El comando se define en docker-compose para cada servicio (backend, worker, beat, flower) diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/cards/__init__.py b/api/cards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/cards/admin.py b/api/cards/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/api/cards/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/api/cards/apps.py b/api/cards/apps.py new file mode 100644 index 0000000..fa2427f --- /dev/null +++ b/api/cards/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CardsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.cards' diff --git a/api/cards/models.py b/api/cards/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/api/cards/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/api/cards/tests.py b/api/cards/tests.py new file mode 100644 index 0000000..0d91c26 --- /dev/null +++ b/api/cards/tests.py @@ -0,0 +1,79 @@ + +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from api.organization.models import Organizacion +from api.record.models import Document +from api.logger.models import UserActivity, RequestLog +from api.customs.models import ProcesamientoPedimento + +User = get_user_model() + +class CardsViewsTests(APITestCase): + def setUp(self): + self.org = Organizacion.objects.create(nombre="OrgTest", is_active=True, is_verified=True) + self.org2 = Organizacion.objects.create(nombre="OrgTest2", is_active=True, is_verified=True) + self.admin = User.objects.create_user(username="admin", password="adminpass", organizacion=self.org) + self.admin.groups.create(name="admin") + self.superuser = User.objects.create_superuser(username="superuser", password="superpass") + self.importador = User.objects.create_user(username="importador", password="importpass", organizacion=self.org2, is_importador=True, rfc="RFC123456789") + self.importador.groups.create(name="importador") + self.client = APIClient() + def test_admin_sees_only_own_org_documents(self): + from api.record.models import Document + doc1 = Document.objects.create(archivo="file1.pdf", organizacion=self.org) + doc2 = Document.objects.create(archivo="file2.pdf", organizacion=self.org2) + self.client.force_authenticate(user=self.admin) + url = reverse('document-util-information') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Only doc1 should be counted + self.assertGreaterEqual(response.data['archivos_ultimas_1_dia'], 0) + + def test_superuser_sees_all_documents(self): + from api.record.models import Document + doc1 = Document.objects.create(archivo="file1.pdf", organizacion=self.org) + doc2 = Document.objects.create(archivo="file2.pdf", organizacion=self.org2) + self.client.force_authenticate(user=self.superuser) + url = reverse('document-util-information') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Both docs should be counted + self.assertGreaterEqual(response.data['archivos_ultimas_1_dia'], 0) + + def test_importador_cannot_create_document(self): + self.client.force_authenticate(user=self.importador) + url = reverse('document-util-information') + response = self.client.post(url, {}) + self.assertNotIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) + + def test_document_util_information(self): + url = reverse('document-util-information') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('archivos_ultimas_1_dia', response.data) + + def test_services_util_information(self): + url = reverse('pedimento-services-util-information') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('en_espera', response.data) + + def test_user_activity_analysis(self): + url = reverse('user-activity-analysis') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('actions_count', response.data) + + def test_request_log_analysis(self): + url = reverse('request-log-analysis') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('methods_count', response.data) + + def test_last_document_view(self): + url = reverse('downloaded-documents') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('documentos', response.data) diff --git a/api/cards/urls.py b/api/cards/urls.py new file mode 100644 index 0000000..e72b7d1 --- /dev/null +++ b/api/cards/urls.py @@ -0,0 +1,21 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ( + DocumentUtilInformation, + ViewPedimentoServicesUtilInformation, + UserActivityAnalysis, + RequestLogAnalysis, + LastDocumentView, +) + + +# Create a router and register our viewset with it. + +# The API URLs are now determined automatically by the router. +urlpatterns = [ + path('document-util-information/', DocumentUtilInformation.as_view(), name='document-util-information'), + path('services-util-information/', ViewPedimentoServicesUtilInformation.as_view(), name='pedimento-services-util-information'), + path('user-activity-analysis/', UserActivityAnalysis.as_view(), name='user-activity-analysis'), + path('request-log-analysis/', RequestLogAnalysis.as_view(), name='request-log-analysis'), + path('downloaded-documents/', LastDocumentView.as_view(), name='downloaded-documents'), +] \ No newline at end of file diff --git a/api/cards/views.py b/api/cards/views.py new file mode 100644 index 0000000..7c59710 --- /dev/null +++ b/api/cards/views.py @@ -0,0 +1,457 @@ +from django.shortcuts import render +from django.contrib.auth import get_user_model +from django.utils import timezone +from django.db.models import F +from datetime import timedelta +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from core.permissions import ( + IsSameOrganization, + IsSameOrganizationDeveloper, + IsSameOrganizationAndAdmin, + IsSuperUser +) + +from api.organization.models import UsoAlmacenamiento, Organizacion +from api.record.models import Document +from api.customs.models import ProcesamientoPedimento +from api.logger.models import UserActivity, RequestLog + +from api.logger.mixins import LoggingMixin +from mixins.filtrado_organizacion import FiltroPorOrganizacionMixin, DocumentosFiltradosMixin + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from api.logger.models import UserActivity, RequestLog, UserActivity + + +# Create your views here. +class DocumentUtilInformation(LoggingMixin, APIView, FiltroPorOrganizacionMixin): + """ + View to get the total storage used by the organization and stats of documents added in last 1, 7, and 30 days. + Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + model = Document + + my_tags = ['Cards'] + + @swagger_auto_schema( + operation_description="Get total storage used and document stats. Permite filtrar por fecha de documentos.", + manual_parameters=[ + openapi.Parameter('fecha_inicio', openapi.IN_QUERY, description="Fecha de inicio (YYYY-MM-DD)", type=openapi.TYPE_STRING), + openapi.Parameter('fecha_fin', openapi.IN_QUERY, description="Fecha de fin (YYYY-MM-DD)", type=openapi.TYPE_STRING), + ], + responses={ + 200: openapi.Response( + description="Document stats", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "archivos_ultimas_1_dia": openapi.Schema(type=openapi.TYPE_INTEGER, description="Archivos en el último día"), + "archivos_ultimos_7_dias": openapi.Schema(type=openapi.TYPE_INTEGER, description="Archivos en los últimos 7 días"), + "archivos_ultimos_30_dias": openapi.Schema(type=openapi.TYPE_INTEGER, description="Archivos en los últimos 30 días"), + "archivos_filtrados": openapi.Schema(type=openapi.TYPE_INTEGER, description="Archivos en el rango de fechas") + } + ), + examples={ + "application/json": { + "archivos_ultimas_1_dia": 5, + "archivos_ultimos_7_dias": 20, + "archivos_ultimos_30_dias": 50, + "archivos_filtrados": 10 + } + } + ) + } + ) + + def get_queryset(self): + return self.get_queryset_filtrado() + + def get(self, request): + queryset = self.get_queryset() + now = timezone.now() + count_1 = queryset.filter(created_at__gte=now - timedelta(days=1)).count() + count_7 = queryset.filter(created_at__gte=now - timedelta(days=7)).count() + count_30 = queryset.filter(created_at__gte=now - timedelta(days=30)).count() + + fecha_inicio = request.query_params.get('fecha_inicio') + fecha_fin = request.query_params.get('fecha_fin') + docs_filtrados = queryset + + if fecha_inicio: + docs_filtrados = docs_filtrados.filter(created_at__gte=fecha_inicio) + if fecha_fin: + docs_filtrados = docs_filtrados.filter(created_at__lte=fecha_fin) + count_filtrados = docs_filtrados.count() + return Response({ + "archivos_ultimas_1_dia": count_1, + "archivos_ultimos_7_dias": count_7, + "archivos_ultimos_30_dias": count_30, + "archivos_filtrados": count_filtrados + }) + +class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrganizacionMixin): + """ + View para obtener información de uso de servicios relacionados con pedimentos. + Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización. + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + model = Document + my_tags = ['Cards'] + + @swagger_auto_schema( + operation_description="Get services stats. Permite filtrar por fecha de procesos.", + manual_parameters=[ + openapi.Parameter('fecha_inicio', openapi.IN_QUERY, description="Fecha de inicio (YYYY-MM-DD)", type=openapi.TYPE_STRING), + openapi.Parameter('fecha_fin', openapi.IN_QUERY, description="Fecha de fin (YYYY-MM-DD)", type=openapi.TYPE_STRING), + ], + responses={ + 200: openapi.Response( + description="Services stats", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "en_espera": openapi.Schema(type=openapi.TYPE_INTEGER, description="Cantidad de procesos en espera"), + "en_proceso": openapi.Schema(type=openapi.TYPE_INTEGER, description="Cantidad de procesos en proceso"), + "finalizados": openapi.Schema(type=openapi.TYPE_INTEGER, description="Cantidad de procesos finalizados"), + "con_error": openapi.Schema(type=openapi.TYPE_INTEGER, description="Cantidad de procesos con error"), + "procesos_filtrados": openapi.Schema(type=openapi.TYPE_INTEGER, description="Procesos en el rango de fechas") + } + ), + examples={ + "application/json": { + "en_espera": 1, + "en_proceso": 2, + "finalizados": 3, + "con_error": 4, + "procesos_filtrados": 5 + } + } + ) + } + ) + + def get_queryset(self): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return None + + # Si es super usuario, devuelve todos los procesos + if self.request.user.is_superuser: + return ProcesamientoPedimento.objects.all() + + # Si es Administrador de la organizacion devuelve todos los servicios de la organizacion + if self.request.user.is_authenticated and self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists(): + return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion) + + # Si es Desarrollador de la organizacion devuelve todos los servicios de la organizacion + if self.request.user.is_authenticated and self.request.user.groups.filter(name='developer').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists(): + return self.request.user.organizacion.procesamiento_pedimentos.all() + + if self.request.user.is_authenticated and self.request.user.groups.filter(name='user').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists(): + return self.request.user.organizacion.procesamiento_pedimentos.all() + + # Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos + if self.request.user.is_authenticated and self.request.user.groups.filter(name='importador').exists() and self.request.user.is_importador and self.request.user.groups.filter(name='user').exists(): + return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente=self.request.user.rfc) + + + + # Si es parte de una organización, filtrar por esa organización + return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion) + + def get(self, request): + queryset = self.get_queryset() + if queryset is None: + return Response({"error": "Usuario no autenticado o sin organización"}, status=401) + en_espera = queryset.filter(estado=1).count() + en_proceso = queryset.filter(estado=2).count() + finalizados = queryset.filter(estado=3).count() + con_error = queryset.filter(estado=4).count() + fecha_inicio = request.query_params.get('fecha_inicio') + fecha_fin = request.query_params.get('fecha_fin') + procesos_filtrados = queryset + if fecha_inicio: + procesos_filtrados = procesos_filtrados.filter(created_at__gte=fecha_inicio) + if fecha_fin: + procesos_filtrados = procesos_filtrados.filter(created_at__lte=fecha_fin) + count_filtrados = procesos_filtrados.count() + return Response({ + "en_espera": en_espera, + "en_proceso": en_proceso, + "finalizados": finalizados, + "con_error": con_error, + "procesos_filtrados": count_filtrados + }) + +class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin): + """ + Endpoint para análisis de actividades de usuario. + Devuelve el conteo de acciones por tipo y los 5 usuarios más activos. + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + + model = UserActivity + + my_tags = ['Cards'] + + @swagger_auto_schema( + operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.", + manual_parameters=[ + openapi.Parameter('fecha_inicio', openapi.IN_QUERY, description="Fecha de inicio (YYYY-MM-DD)", type=openapi.TYPE_STRING), + openapi.Parameter('fecha_fin', openapi.IN_QUERY, description="Fecha de fin (YYYY-MM-DD)", type=openapi.TYPE_STRING), + ], + responses={ + 200: openapi.Response( + description="User activity analysis", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "actions_count": openapi.Schema( + type=openapi.TYPE_OBJECT, + additional_properties=openapi.Schema(type=openapi.TYPE_INTEGER, description="Cantidad por acción") + ), + "top_users": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "username": openapi.Schema(type=openapi.TYPE_STRING), + "activity_count": openapi.Schema(type=openapi.TYPE_INTEGER) + } + ), + description="Top 5 usuarios más activos" + ), + "actividades_filtradas": openapi.Schema(type=openapi.TYPE_INTEGER, description="Actividades en el rango de fechas") + } + ), + examples={ + "application/json": { + "actions_count": { + "login": 20, + "logout": 18, + "create": 15, + "update": 10, + "delete": 5, + "view": 30, + "search": 12, + "export": 3, + "import": 2 + }, + "top_users": [ + {"username": "admin", "activity_count": 25}, + {"username": "user1", "activity_count": 20} + ], + "actividades_filtradas": 10 + } + } + ) + } + ) + def get_queryset(self): + return self.get_queryset_filtrado() + + def get(self, request): + queryset = self.get_queryset() + User = get_user_model() + actions = [a[0] for a in UserActivity.ACTIONS] + actions_count = {a: queryset.filter(action=a).count() for a in actions} + # Top 5 usuarios más activos + top_users = [] + from django.db.models import Count + top_users_qs = queryset.values('user').annotate(activity_count=Count('id')).order_by('-activity_count')[:5] + for entry in top_users_qs: + try: + user_obj = User.objects.get(pk=entry['user']) + top_users.append({"username": user_obj.username, "activity_count": entry['activity_count']}) + except User.DoesNotExist: + continue + fecha_inicio = request.query_params.get('fecha_inicio') + fecha_fin = request.query_params.get('fecha_fin') + actividades_filtradas = queryset + if fecha_inicio: + actividades_filtradas = actividades_filtradas.filter(timestamp__gte=fecha_inicio) + if fecha_fin: + actividades_filtradas = actividades_filtradas.filter(timestamp__lte=fecha_fin) + count_filtrados = actividades_filtradas.count() + return Response({ + "actions_count": actions_count, + "top_users": top_users, + "actividades_filtradas": count_filtrados + }) + +class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin): + """ + Endpoint para análisis de logs de peticiones. + Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta. + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + model = RequestLog + + my_tags = ['Cards'] + + @swagger_auto_schema( + operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.", + manual_parameters=[ + openapi.Parameter('fecha_inicio', openapi.IN_QUERY, description="Fecha de inicio (YYYY-MM-DD)", type=openapi.TYPE_STRING), + openapi.Parameter('fecha_fin', openapi.IN_QUERY, description="Fecha de fin (YYYY-MM-DD)", type=openapi.TYPE_STRING), + ], + responses={ + 200: openapi.Response( + description="Request log analysis", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "methods_count": openapi.Schema( + type=openapi.TYPE_OBJECT, + additional_properties=openapi.Schema(type=openapi.TYPE_INTEGER, description="Cantidad por método") + ), + "top_paths": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "path": openapi.Schema(type=openapi.TYPE_STRING), + "count": openapi.Schema(type=openapi.TYPE_INTEGER) + } + ), + description="Top 5 paths más solicitados" + ), + "avg_response_time": openapi.Schema(type=openapi.TYPE_NUMBER, format='float', description="Promedio de tiempo de respuesta (ms)"), + "logs_filtrados": openapi.Schema(type=openapi.TYPE_INTEGER, description="Logs en el rango de fechas") + } + ), + examples={ + "application/json": { + "methods_count": { + "GET": 120, + "POST": 30, + "PUT": 10, + "DELETE": 5 + }, + "top_paths": [ + {"path": "/api/v1/record/documents/", "count": 50}, + {"path": "/api/v1/customs/pedimentos/", "count": 40} + ], + "avg_response_time": 120.5, + "logs_filtrados": 15 + } + } + ) + } + ) + def get_queryset(self): + return self.get_queryset_filtrado() + + def get(self, request): + queryset = self.get_queryset() + from django.db.models import Count, Avg + from api.logger.models import RequestLog + methods = [m[0] for m in RequestLog.METHODS] + methods_count = {m: queryset.filter(method=m).count() for m in methods} + top_paths_qs = queryset.values('path').annotate(count=Count('id')).order_by('-count')[:5] + top_paths = [{"path": entry['path'], "count": entry['count']} for entry in top_paths_qs] + avg_response_time = queryset.aggregate(avg=Avg('response_time'))['avg'] or 0.0 + fecha_inicio = request.query_params.get('fecha_inicio') + fecha_fin = request.query_params.get('fecha_fin') + logs_filtrados = queryset + if fecha_inicio: + logs_filtrados = logs_filtrados.filter(timestamp__gte=fecha_inicio) + if fecha_fin: + logs_filtrados = logs_filtrados.filter(timestamp__lte=fecha_fin) + count_filtrados = logs_filtrados.count() + return Response({ + "methods_count": methods_count, + "top_paths": top_paths, + "avg_response_time": round(avg_response_time, 2), + "logs_filtrados": count_filtrados + }) + +class LastDocumentView(LoggingMixin, APIView, DocumentosFiltradosMixin): + """ + View que obtiene los ultimos 10 documentos agregados. + Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + model = Document + + my_tags = ['Cards'] + + @swagger_auto_schema( + operation_description="Obtiene los últimos 10 documentos agregados. Permite filtrar por fecha de creación.", + manual_parameters=[ + openapi.Parameter('fecha_inicio', openapi.IN_QUERY, description="Fecha de inicio (YYYY-MM-DD)", type=openapi.TYPE_STRING), + openapi.Parameter('fecha_fin', openapi.IN_QUERY, description="Fecha de fin (YYYY-MM-DD)", type=openapi.TYPE_STRING), + ], + responses={ + 200: openapi.Response( + description="Lista de los últimos 10 documentos", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "documentos": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "id": openapi.Schema(type=openapi.TYPE_STRING, format="uuid"), + "archivo": openapi.Schema(type=openapi.TYPE_STRING, description="Ruta del archivo"), + "extension": openapi.Schema(type=openapi.TYPE_STRING), + "size": openapi.Schema(type=openapi.TYPE_INTEGER), + "created_at": openapi.Schema(type=openapi.TYPE_STRING, format="date-time"), + "updated_at": openapi.Schema(type=openapi.TYPE_STRING, format="date-time"), + "organizacion": openapi.Schema(type=openapi.TYPE_STRING), + "pedimento": openapi.Schema(type=openapi.TYPE_STRING) + } + ), + description="Últimos 10 documentos agregados" + ), + "total_filtrados": openapi.Schema(type=openapi.TYPE_INTEGER, description="Total de documentos en el rango de fechas") + } + ), + examples={ + "application/json": { + "documentos": [ + {"id": "b7e6c8e2-1a2b-4c3d-8e9f-123456789abc", "archivo": "documents/doc1.pdf", "extension": "pdf", "size": 123456, "created_at": "2025-07-14T10:00:00Z", "updated_at": "2025-07-14T10:00:00Z", "organizacion": "Org1", "pedimento": "Ped1"} + ], + "total_filtrados": 1 + } + } + ) + } + ) + def get_queryset(self): + return self.get_queryset_filtrado_por_organizacion() + + def get(self, request): + queryset = self.get_queryset() + fecha_inicio = request.query_params.get('fecha_inicio') + fecha_fin = request.query_params.get('fecha_fin') + documentos = queryset + if fecha_inicio: + documentos = documentos.filter(created_at__gte=fecha_inicio) + if fecha_fin: + documentos = documentos.filter(created_at__lte=fecha_fin) + total_filtrados = documentos.count() + ultimos = documentos[:10] + docs_serializados = [] + for doc in ultimos: + docs_serializados.append({ + "id": str(doc.id), + "archivo": doc.archivo.name if doc.archivo else '', + "extension": doc.extension, + "size": doc.size, + "created_at": doc.created_at.isoformat() if doc.created_at else '', + "updated_at": doc.updated_at.isoformat() if doc.updated_at else '', + "organizacion": str(doc.organizacion) if doc.organizacion else '', + "pedimento": str(doc.pedimento) if doc.pedimento else '' + }) + return Response({ + "documentos": docs_serializados, + "total_filtrados": total_filtrados + }) + \ No newline at end of file diff --git a/api/cuser/__init__.py b/api/cuser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/cuser/admin.py b/api/cuser/admin.py new file mode 100644 index 0000000..d4553d9 --- /dev/null +++ b/api/cuser/admin.py @@ -0,0 +1,51 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.forms import UserCreationForm, UserChangeForm +from .models import CustomUser + + +class CustomUserCreationForm(UserCreationForm): + class Meta: + model = CustomUser + fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture', 'password1', 'password2') + + +class CustomUserChangeForm(UserChangeForm): + class Meta: + model = CustomUser + fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture') + + +class CustomUserAdmin(UserAdmin): + add_form = CustomUserCreationForm + form = CustomUserChangeForm + model = CustomUser + + list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'is_importador', 'organizacion') + list_filter = ('is_staff', 'is_active', 'organizacion') + search_fields = ('username', 'email', 'first_name', 'last_name') + ordering = ('username',) + + # Fieldsets para editar un usuario + fieldsets = ( + (None, {'fields': ('username', 'password')}), + ('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'profile_picture', 'is_importador', 'rfc')}), + ('Permisos', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), + ('Fechas importantes', {'fields': ('last_login', 'date_joined')}), + ) + + # Fieldsets para crear un nuevo usuario + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ( + 'username', 'email', 'first_name', 'last_name', + 'organizacion', 'profile_picture', + 'password1', 'password2', + 'is_staff', 'is_active', 'is_importador' + ) + }), + ) + + +admin.site.register(CustomUser, CustomUserAdmin) diff --git a/api/cuser/apps.py b/api/cuser/apps.py new file mode 100644 index 0000000..eed75bd --- /dev/null +++ b/api/cuser/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CuserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.cuser' diff --git a/api/cuser/jwt_cookie_views.py b/api/cuser/jwt_cookie_views.py new file mode 100644 index 0000000..3c6ebaf --- /dev/null +++ b/api/cuser/jwt_cookie_views.py @@ -0,0 +1,45 @@ +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from rest_framework.response import Response +from rest_framework import status +from django.conf import settings + +class CookieTokenObtainPairView(TokenObtainPairView): + """ + Custom view to set JWT tokens as HttpOnly cookies. + """ + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + if response.status_code == 200: + access = response.data.get('access') + refresh = response.data.get('refresh') + # Remove tokens from body (optional, for extra security) + response.data.pop('access', None) + response.data.pop('refresh', None) + # Set cookies + cookie_settings = { + 'httponly': True, + 'secure': True, # Set to True if using HTTPS + 'samesite': 'Lax', + 'path': '/' + } + response.set_cookie('access_token', access, max_age=60*5, **cookie_settings) # 5 min + response.set_cookie('refresh_token', refresh, max_age=60*60*24*7, **cookie_settings) # 7 days + return response + +class CookieTokenRefreshView(TokenRefreshView): + """ + Custom view to refresh JWT tokens and set as HttpOnly cookies. + """ + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + if response.status_code == 200: + access = response.data.get('access') + response.data.pop('access', None) + cookie_settings = { + 'httponly': True, + 'secure': True, # Set to True if using HTTPS + 'samesite': 'Lax', + 'path': '/' + } + response.set_cookie('access_token', access, max_age=60*5, **cookie_settings) # 5 min + return response diff --git a/api/cuser/migrations/0001_initial.py b/api/cuser/migrations/0001_initial.py new file mode 100644 index 0000000..41327f2 --- /dev/null +++ b/api/cuser/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('organization', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('profile_picture', models.ImageField(blank=True, null=True, upload_to='profile_pictures/')), + ('is_importador', models.BooleanField(default=False, help_text='Indicates if the user is an importer')), + ('rfc', models.CharField(blank=True, help_text='RFC of the user', max_length=13, null=True, unique=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='organization.organizacion')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'Custom User', + 'verbose_name_plural': 'Custom Users', + 'ordering': ['username'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/api/cuser/migrations/0002_alter_customuser_rfc.py b/api/cuser/migrations/0002_alter_customuser_rfc.py new file mode 100644 index 0000000..02835df --- /dev/null +++ b/api/cuser/migrations/0002_alter_customuser_rfc.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.3 on 2025-07-30 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cuser', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='rfc', + field=models.CharField(blank=True, help_text='RFC of the user', max_length=13, null=True), + ), + ] diff --git a/api/cuser/migrations/0003_alter_customuser_rfc.py b/api/cuser/migrations/0003_alter_customuser_rfc.py new file mode 100644 index 0000000..8578eb3 --- /dev/null +++ b/api/cuser/migrations/0003_alter_customuser_rfc.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.3 on 2025-07-31 16:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cuser', '0002_alter_customuser_rfc'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='rfc', + field=models.CharField(blank=True, help_text='RFC of the user', max_length=1, null=True), + ), + ] diff --git a/api/cuser/migrations/__init__.py b/api/cuser/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/cuser/models.py b/api/cuser/models.py new file mode 100644 index 0000000..e8a1547 --- /dev/null +++ b/api/cuser/models.py @@ -0,0 +1,23 @@ +import uuid +from django.db import models +from django.contrib.auth.models import AbstractUser + +# Create your models here. +class CustomUser(AbstractUser): + """ + Custom user model that extends the default Django user model. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, null=True, blank=True, related_name='users') + profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True) + + is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer") + rfc = models.CharField(max_length=1, null=True, blank=True, help_text="RFC of the user") + + def __str__(self): + return self.username + + class Meta: + verbose_name = 'Custom User' + verbose_name_plural = 'Custom Users' + ordering = ['username'] \ No newline at end of file diff --git a/api/cuser/password_reset_utils.py b/api/cuser/password_reset_utils.py new file mode 100644 index 0000000..acae3eb --- /dev/null +++ b/api/cuser/password_reset_utils.py @@ -0,0 +1,24 @@ +from django.core.mail import EmailMultiAlternatives +from django.conf import settings +from django.urls import reverse +from django.utils.http import urlsafe_base64_encode +from django.utils.encoding import force_bytes +from django.contrib.auth.tokens import default_token_generator + +from django.template.loader import render_to_string + +def send_password_reset_email(user, request): + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + # Enlace directo al frontend + reset_link = f"https://efc-aduanasoft.com/user/password-reset-confirm/{uid}/{token}/" + subject = 'Recuperación de contraseña' + context = { + 'username': user.username, + 'reset_link': reset_link, + } + text_content = f'Hola {user.username},\nPara restablecer tu contraseña haz clic en el siguiente enlace: {reset_link}' + html_content = render_to_string('email/password_reset_email.html', context) + email = EmailMultiAlternatives(subject, text_content, settings.DEFAULT_FROM_EMAIL, [user.email]) + email.attach_alternative(html_content, "text/html") + email.send(fail_silently=False) diff --git a/api/cuser/serializers.py b/api/cuser/serializers.py new file mode 100644 index 0000000..5549ab1 --- /dev/null +++ b/api/cuser/serializers.py @@ -0,0 +1,29 @@ + +from rest_framework import serializers +from .models import CustomUser +from django.contrib.auth.models import Group + +class CustomUserSerializer(serializers.ModelSerializer): + """ + Serializer for the CustomUser model. + """ + + password = serializers.CharField(write_only=True) + groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False) + rfc = serializers.CharField(max_length=20, required=False, allow_blank=True) + + + class Meta: + model = CustomUser + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'profile_picture', 'organizacion', 'is_importador', 'rfc', 'is_active', 'is_superuser', 'groups'] + read_only_fields = ['id', 'organizacion', 'is_superuser'] + + def create(self, validated_data): + groups = validated_data.pop('groups', []) + password = validated_data.pop('password') + user = CustomUser(**validated_data) + user.set_password(password) + user.save() + if groups: + user.groups.set(groups) + return user diff --git a/api/cuser/tests.py b/api/cuser/tests.py new file mode 100644 index 0000000..87b39c9 --- /dev/null +++ b/api/cuser/tests.py @@ -0,0 +1,91 @@ + +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from api.organization.models import Organizacion +from .models import CustomUser + +User = get_user_model() + +class CustomUserViewSetTests(APITestCase): + def setUp(self): + self.org = Organizacion.objects.create(nombre="OrgTest", is_active=True, is_verified=True) + self.org2 = Organizacion.objects.create(nombre="OrgTest2", is_active=True, is_verified=True) + self.admin = User.objects.create_user(username="admin", password="adminpass", organizacion=self.org) + self.admin.groups.create(name="admin") + self.superuser = User.objects.create_superuser(username="superuser", password="superpass") + self.importador = User.objects.create_user(username="importador", password="importpass", organizacion=self.org2, is_importador=True, rfc="RFC123456789") + self.importador.groups.create(name="importador") + self.user = User.objects.create_user(username="user1", password="userpass", organizacion=self.org) + self.client = APIClient() + def test_admin_sees_only_own_org_users(self): + user2 = User.objects.create_user(username="user2", password="userpass2", organizacion=self.org2) + self.client.force_authenticate(user=self.admin) + url = reverse('customuser-list') + response = self.client.get(url) + usernames = [u['username'] for u in response.data] + self.assertIn("admin", usernames) + self.assertIn("user1", usernames) + self.assertNotIn("user2", usernames) + + def test_superuser_sees_all_users(self): + user2 = User.objects.create_user(username="user2", password="userpass2", organizacion=self.org2) + self.client.force_authenticate(user=self.superuser) + url = reverse('customuser-list') + response = self.client.get(url) + usernames = [u['username'] for u in response.data] + self.assertIn("admin", usernames) + self.assertIn("user1", usernames) + self.assertIn("user2", usernames) + + def test_importador_cannot_create_user(self): + self.client.force_authenticate(user=self.importador) + url = reverse('customuser-list') + data = { + "username": "newuser", + "email": "newuser@example.com", + "first_name": "New", + "last_name": "User", + "password": "newpass123" + } + response = self.client.post(url, data) + self.assertNotIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) + + def test_list_users(self): + url = reverse('customuser-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(len(response.data) >= 1) + + def test_me_endpoint(self): + url = reverse('customuser-me') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['username'], self.admin.username) + + def test_create_user_as_admin(self): + url = reverse('customuser-list') + data = { + "username": "newuser", + "email": "newuser@example.com", + "first_name": "New", + "last_name": "User", + "password": "newpass123" + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['username'], "newuser") + + def test_update_user_as_admin(self): + url = reverse('customuser-detail', args=[str(self.user.id)]) + data = {"first_name": "Updated"} + response = self.client.patch(url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], "Updated") + + def test_profile_picture_view(self): + # No profile picture, should return 404 + url = reverse('profile-picture', args=[str(self.user.id)]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/api/cuser/urls.py b/api/cuser/urls.py new file mode 100644 index 0000000..ba85c9b --- /dev/null +++ b/api/cuser/urls.py @@ -0,0 +1,18 @@ +from django.urls import path, include +from .views import ProfilePictureView, ActivateUserView, PasswordResetRequestView, PasswordResetConfirmView, CustomUserViewSet +customuser_me = CustomUserViewSet.as_view({'get': 'me'}) +from rest_framework.routers import DefaultRouter +from .views import CustomUserViewSet + +router = DefaultRouter() +router.register(r'users', CustomUserViewSet, basename='customuser') + + +urlpatterns = [ + path('', include(router.urls)), + path('me/', customuser_me, name='user-me'), + path('profile-picture//', ProfilePictureView.as_view(), name='profile-picture'), + path('activate///', ActivateUserView.as_view(), name='activate'), + path('password-reset/', PasswordResetRequestView.as_view(), name='password_reset'), + path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), +] \ No newline at end of file diff --git a/api/cuser/utils.py b/api/cuser/utils.py new file mode 100644 index 0000000..1752d70 --- /dev/null +++ b/api/cuser/utils.py @@ -0,0 +1,28 @@ +import uuid +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.http import urlsafe_base64_encode +from django.utils.encoding import force_bytes +from django.contrib.auth.tokens import default_token_generator + +def send_activation_email(user, request): + """ + Envía un correo de activación al usuario con un link único. + """ + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + activation_link = request.build_absolute_uri( + reverse('cuser:activate', kwargs={'uidb64': uid, 'token': token}) + ) + subject = 'Activa tu cuenta' + context = { + 'username': user.username, + 'activation_link': activation_link, + } + text_content = f'Hola {user.username},\nPor favor haz clic en el siguiente enlace para activar tu cuenta: {activation_link}' + html_content = render_to_string('email/activation_email.html', context) + email = EmailMultiAlternatives(subject, text_content, settings.DEFAULT_FROM_EMAIL, [user.email]) + email.attach_alternative(html_content, "text/html") + email.send(fail_silently=False) diff --git a/api/cuser/views.py b/api/cuser/views.py new file mode 100644 index 0000000..5df97a6 --- /dev/null +++ b/api/cuser/views.py @@ -0,0 +1,275 @@ +from .password_reset_utils import send_password_reset_email +from django.contrib.auth import get_user_model +from django.utils.encoding import force_str + +# Vista para solicitar recuperación de contraseña +from rest_framework import status + +import uuid +from django.http import FileResponse, Http404 +from django.shortcuts import get_object_or_404, redirect +from rest_framework.views import APIView +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.pagination import PageNumberPagination + +from rest_framework.exceptions import PermissionDenied +from core.permissions import ( + IsSameOrganization, + IsSameOrganizationDeveloper, + IsSameOrganizationAndAdmin, + IsSuperUser +) + +from .serializers import CustomUserSerializer +from .models import CustomUser +from api.logger.mixins import LoggingMixin +from api.vucem.models import Vucem +from mixins.filtrado_organizacion import OrganizacionFiltradaMixin + +from .utils import send_activation_email +from django.utils.http import urlsafe_base64_decode +from django.contrib.auth.tokens import default_token_generator +from rest_framework.views import APIView +from django.utils.encoding import force_str +from django.conf import settings + +class CustomPagination(PageNumberPagination): + + """ + Paginación personalizada con parámetros flexibles + - Si no se especifica page_size, devuelve todos los resultados (sin paginación) + - Si se especifica page_size, usa paginación normal + """ + page_size = None # Sin paginación por defecto + page_size_query_param = 'page_size' + max_page_size = 1000 # Límite máximo de seguridad + page_query_param = 'page' + + def paginate_queryset(self, queryset, request, view=None): + """ + Si no se especifica page_size en los parámetros, devolver None (sin paginación) + Si se especifica, usar paginación normal + """ + # Verificar si se especificó page_size en la query + if self.page_size_query_param not in request.query_params: + # No hay page_size, devolver None para indicar "sin paginación" + return None + + # Hay page_size, usar paginación normal + try: + page_size = int(request.query_params[self.page_size_query_param]) + if page_size <= 0: + return None + # Establecer el page_size temporalmente para esta request + self.page_size = min(page_size, self.max_page_size) + except (ValueError, TypeError): + return None + + return super().paginate_queryset(queryset, request, view) + +class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): + """ + ViewSet for CustomUser model. + """ + permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSameOrganization )] + pagination_class = CustomPagination + model = CustomUser + serializer_class = CustomUserSerializer + filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador'] + my_tags = ['User Profile'] + + + def get_permissions(self): + # Permitir eliminar usuarios solo a admin, Agente Aduanal y user de la misma organización + if self.action == 'destroy': + user = self.request.user + if not ( + user.is_superuser or + user.groups.filter(name='admin').exists() or + user.groups.filter(name='Agente Aduanal').exists() or + user.groups.filter(name='user').exists() + ): + from rest_framework.exceptions import PermissionDenied + raise PermissionDenied("Solo admin, Agente Aduanal o user pueden eliminar usuarios.") + elif self.action in ['create', 'update', 'partial_update']: + if not (self.request.user.is_superuser or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Importador').exists()) : + from rest_framework.exceptions import PermissionDenied + raise PermissionDenied("Solo admin o superusuario pueden modificar usuarios.") + return super().get_permissions() + + def perform_destroy(self, instance): + # Solo permitir eliminar usuarios de la misma organización + if self.request.user.is_superuser or instance.organizacion == self.request.user.organizacion: + instance.delete() + else: + from rest_framework.exceptions import PermissionDenied + raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.") + + def get_queryset(self): + # Si es importador, solo puede ver su propio usuario + if self.request.user.groups.filter(name='importador').exists() or self.request.user.groups.filter(name='Importador').exists(): + return CustomUser.objects.filter(pk=self.request.user.pk) + + # Otros roles: filtrar por organización + return self.get_queryset_filtrado_por_organizacion() + + def perform_create(self, serializer): + # Always assign the creator's organization + if self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists(): + if not self.request.user.organizacion: + raise PermissionDenied("Los administradores deben tener una organización asignada para crear usuarios.") + user = serializer.save(organizacion=self.request.user.organizacion, is_active=False) + send_activation_email(user, self.request) # Usa template HTML + return + + if self.request.user.is_superuser: + # If superuser, allow creating users without organization + user = serializer.save(is_active=False) + send_activation_email(user, self.request) # Usa template HTML + return + + if self.request.user.groups.filter(name='developer').exists(): + # Developers can create users but must assign an organization + if not self.request.user.organizacion: + raise PermissionDenied("Los desarrolladores deben tener una organización asignada para crear usuarios.") + user = serializer.save(organizacion=self.request.user.organizacion, is_active=False) + send_activation_email(user, self.request) # Usa template HTML + return + + if self.request.user.groups.filter(name='importador').exists(): + # No puedes crear un usuario si eres importador + raise PermissionDenied("Los importadores no pueden crear usuarios.") + + user = serializer.save(organizacion=self.request.user.organizacion, is_active=False) + send_activation_email(user, self.request) # Usa template HTML + return + + @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) + def me(self, request): + """ + Endpoint para obtener la información del usuario autenticado. + GET /api/v1/user/me/ + """ + serializer = self.get_serializer(request.user) + return Response(serializer.data) + + + @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) + def change_password(self, request, pk=None): + """ + Endpoint para cambiar la contraseña de un usuario. + Solo el propio usuario o un admin/superuser pueden cambiarla. + POST /user/users/{id}/change_password/ + Body: {"old_password": "actual", "new_password": "nueva"} + """ + user = self.get_object() + current_user = request.user + # Solo el propio usuario, admin o superuser pueden cambiar la contraseña + if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists() or user == current_user): + raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.") + + old_password = request.data.get('old_password') + new_password = request.data.get('new_password') + if not new_password: + return Response({'detail': 'La nueva contraseña es requerida.'}, status=400) + + # Si no es admin/superuser, debe validar old_password + if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists()): + if not old_password or not user.check_password(old_password): + return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400) + + user.set_password(new_password) + user.save() + return Response({'detail': 'Contraseña cambiada correctamente.'}, status=200) + +class ActivateUserView(APIView): + """ + Vista para activar usuario desde el link enviado por correo. + """ + permission_classes = [] # Permitir acceso público a la activación de usuario + my_tags = ['User Authentication'] + + def get(self, request, uidb64, token): + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + from .models import CustomUser + user = CustomUser.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, CustomUser.DoesNotExist): + user = None + if user is not None and default_token_generator.check_token(user, token): + user.is_active = True + user.save() + # Aquí puedes redirigir a una página de éxito o login + return redirect(settings.SITE_URL + 'login?activated=1') + else: + return Response({'detail': 'El enlace de activación no es válido o ha expirado.'}, status=400) + + def perform_update(self, serializer): + # Only allow update if user is in the same organization + instance = self.get_object() + if instance.organizacion != self.request.user.organizacion: + from rest_framework.exceptions import PermissionDenied + raise PermissionDenied("Solo puedes actualizar usuarios de tu organización.") + + password = serializer.validated_data.pop('password', None) + + user = serializer.save() + if password: + user.set_password(password) + user.save() + +class ProfilePictureView(LoggingMixin, APIView): + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + my_tags = ['User Profile'] + + def get(self, request, user_id): + # Obtiene el usuario (automáticamente 404 si no existe) + user = get_object_or_404(CustomUser, pk=user_id) + + # El permiso IsOwnerOrAdmin ya verificó que request.user == user o es admin + # Así que no necesitas validar manualmente los permisos aquí. + + if not user.profile_picture: + raise Http404("El usuario no tiene imagen de perfil") + + return FileResponse(user.profile_picture.open('rb')) + +class PasswordResetRequestView(APIView): + permission_classes = [] # Permitir acceso público a la recuperación de contraseña + my_tags = ['User Authentication'] + def post(self, request): + email = request.data.get('email') + username = request.data.get('username') + if not email or not username: + return Response({'detail': 'Se requieren username y email.'}, status=400) + User = get_user_model() + try: + user = User.objects.get(email=email, username=username) + except User.DoesNotExist: + return Response({'detail': 'No existe usuario con ese username y email.'}, status=404) + send_password_reset_email(user, request) # Usa template HTML + return Response({'detail': 'Se ha enviado un correo para restablecer la contraseña.'}, status=status.HTTP_200_OK) +# Vista para confirmar recuperación de contraseña +class PasswordResetConfirmView(APIView): + permission_classes = [] # Permitir acceso público a la confirmación de recuperación de contraseña + my_tags = ['User Authentication'] + def post(self, request, uidb64, token): + from django.contrib.auth.tokens import default_token_generator + from django.utils.http import urlsafe_base64_decode + User = get_user_model() + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + return Response({'detail': 'Enlace inválido.'}, status=400) + if not default_token_generator.check_token(user, token): + return Response({'detail': 'Token inválido o expirado.'}, status=400) + password = request.data.get('password') + if not password: + return Response({'detail': 'La nueva contraseña es requerida.'}, status=400) + user.set_password(password) + user.save() + return Response({'detail': 'Contraseña restablecida correctamente.'}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/api/customs/__init__.py b/api/customs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/customs/admin.py b/api/customs/admin.py new file mode 100644 index 0000000..dd498a9 --- /dev/null +++ b/api/customs/admin.py @@ -0,0 +1,65 @@ +from django.contrib import admin +from .models import ( + EstadoDeProcesamiento, + Pedimento, + ProcesamientoPedimento, + Servicio, + TipoDeProcesamiento, + TipoOperacion, + EDocument, + Importador +) + +class TipoOperacionAdmin(admin.ModelAdmin): + model = TipoOperacion + list_display = ('id', 'tipo') + search_fields = ('nombre',) + +class PedimentoAdmin(admin.ModelAdmin): + model = Pedimento + list_display = ('id', 'pedimento', 'aduana', 'patente') + search_fields = ('numero',) + list_filter = ('aduana', 'agente_aduanal', 'organizacion') + +class ProcesamientoPedimentoAdmin(admin.ModelAdmin): + model = ProcesamientoPedimento + list_display = ('id', 'estado', 'pedimento', 'created_at', 'updated_at') + search_fields = ('pedimento__pedimento_app', 'organizacion__nombre', 'estado__estado', 'servicio__endpoint') + list_filter = ('estado', 'organizacion__nombre') + +class EstadoDeProcesamientoAdmin(admin.ModelAdmin): + model = EstadoDeProcesamiento + list_display = ('id', 'estado') + search_fields = ('estado',) + +class TipoDeProcesamientoAdmin(admin.ModelAdmin): + model = TipoDeProcesamiento + list_display = ('id', 'tipo') + # Solo 'tipo' es campo directo, los demás no existen en el modelo + list_filter = ['tipo'] + search_fields = ('tipo', 'organizacion', 'estado', 'servicio') + +class ServicioAdmin(admin.ModelAdmin): + model = Servicio + list_display = ('id', 'endpoint', 'descripcion') + search_fields = ('endpoint', 'descripcion') + +class EDocumentAdmin(admin.ModelAdmin): + model = EDocument + list_display = ('id', 'pedimento', 'numero_edocument', 'organizacion') + search_fields = ('numero_edocument', 'pedimento', 'pedimento__pedimento_app') + list_filter = ['organizacion'] + +class ImportadorAdmin(admin.ModelAdmin): + model = Importador + list_display = ('id', 'nombre', 'rfc') + search_fields = ('nombre', 'rfc') + +admin.site.register(TipoOperacion, TipoOperacionAdmin) +admin.site.register(Pedimento, PedimentoAdmin) +admin.site.register(ProcesamientoPedimento, ProcesamientoPedimentoAdmin) +admin.site.register(EstadoDeProcesamiento, EstadoDeProcesamientoAdmin) +admin.site.register(TipoDeProcesamiento, TipoDeProcesamientoAdmin) +admin.site.register(Servicio, ServicioAdmin) +admin.site.register(EDocument, EDocumentAdmin) +admin.site.register(Importador) \ No newline at end of file diff --git a/api/customs/apps.py b/api/customs/apps.py new file mode 100644 index 0000000..fd48f8a --- /dev/null +++ b/api/customs/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class CustomsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.customs' + + def ready(self): + import api.customs.signals \ No newline at end of file diff --git a/api/customs/migrations/0001_initial.py b/api/customs/migrations/0001_initial.py new file mode 100644 index 0000000..d5524c0 --- /dev/null +++ b/api/customs/migrations/0001_initial.py @@ -0,0 +1,224 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organization', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Aduana', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('seccion', models.CharField(max_length=10)), + ('descripcion', models.CharField(max_length=200)), + ], + options={ + 'verbose_name': 'Aduana', + 'verbose_name_plural': 'Aduanas', + 'db_table': 'aduana', + 'ordering': ['seccion'], + }, + ), + migrations.CreateModel( + name='ClavePedimento', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('clave', models.CharField(max_length=10)), + ('descripcion', models.CharField(max_length=200)), + ], + options={ + 'verbose_name': 'Clave de Pedimento', + 'verbose_name_plural': 'Claves de Pedimento', + 'db_table': 'clave_pedimento', + 'ordering': ['clave'], + }, + ), + migrations.CreateModel( + name='EstadoDeProcesamiento', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('estado', models.CharField(max_length=50)), + ], + options={ + 'verbose_name': 'Estado de Procesamiento', + 'verbose_name_plural': 'Estados de Procesamiento', + 'db_table': 'estado_de_procesamiento', + 'ordering': ['estado'], + }, + ), + migrations.CreateModel( + name='Patente', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('numero', models.CharField(max_length=20)), + ('descripcion', models.CharField(max_length=200)), + ], + options={ + 'verbose_name': 'Patente', + 'verbose_name_plural': 'Patentes', + 'db_table': 'patente', + 'ordering': ['numero'], + }, + ), + migrations.CreateModel( + name='Regimen', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('clave', models.CharField(max_length=10)), + ('descripcion', models.CharField(max_length=200)), + ], + options={ + 'verbose_name': 'Regimen', + 'verbose_name_plural': 'Regimenes', + 'db_table': 'regimen', + 'ordering': ['clave'], + }, + ), + migrations.CreateModel( + name='Servicio', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('endpoint', models.CharField(max_length=100)), + ('descripcion', models.TextField(blank=True, null=True)), + ('hora_inicio', models.TimeField(blank=True, max_length=50, null=True)), + ('hora_fin', models.TimeField(blank=True, max_length=50, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Servicio', + 'verbose_name_plural': 'Servicios', + 'db_table': 'servicio', + 'ordering': ['endpoint'], + }, + ), + migrations.CreateModel( + name='TipoDeProcesamiento', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tipo', models.CharField(max_length=50)), + ], + options={ + 'verbose_name': 'Tipo de Procesamiento', + 'verbose_name_plural': 'Tipos de Procesamiento', + 'db_table': 'tipo_de_procesamiento', + 'ordering': ['tipo'], + }, + ), + migrations.CreateModel( + name='TipoOperacion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tipo', models.CharField(max_length=100)), + ('descripcion', models.CharField(max_length=200)), + ], + options={ + 'verbose_name': 'Tipo de Operacion', + 'verbose_name_plural': 'Tipos de Operacion', + 'db_table': 'tipo_operacion', + 'ordering': ['tipo'], + }, + ), + migrations.CreateModel( + name='AgenteAduanal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre', models.CharField(max_length=100)), + ('rfc', models.CharField(blank=True, max_length=13, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id_aduana', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agentes_aduanales', to='customs.aduana')), + ('id_patente', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agentes_aduanales', to='customs.patente')), + ], + options={ + 'verbose_name': 'Agente Aduanal', + 'verbose_name_plural': 'Agentes Aduanales', + 'db_table': 'agente_aduanal', + 'ordering': ['nombre'], + }, + ), + migrations.CreateModel( + name='Pedimento', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('pedimento', models.CharField(help_text='Número de pedimento aduanal', max_length=20, unique=True)), + ('patente', models.CharField(blank=True, help_text='Número de patente aduanal', max_length=20, null=True)), + ('aduana', models.CharField(blank=True, help_text='Clave de la aduana según la clasificación aduanera', max_length=10, null=True)), + ('regimen', models.CharField(blank=True, help_text='Clave del régimen aduanero según la clasificación aduanera', max_length=10, null=True)), + ('clave_pedimento', models.CharField(blank=True, help_text='Clave del pedimento según la clasificación aduanera', max_length=10, null=True)), + ('fecha_inicio', models.DateField(blank=True, help_text='Fecha de inicio del pedimento', null=True)), + ('fecha_fin', models.DateField(blank=True, help_text='Fecha de fin del pedimento', null=True)), + ('fecha_pago', models.DateField(blank=True, help_text='Fecha de pago del pedimento', null=True)), + ('alerta', models.BooleanField(default=False, help_text='Indica si el pedimento tiene una alerta asociada')), + ('contribuyente', models.CharField(blank=True, help_text='Nombre del contribuyente/importador asociado al pedimento', max_length=100, null=True)), + ('agente_aduanal', models.CharField(blank=True, help_text='RFC del agente aduanal', max_length=100, null=True)), + ('curp_apoderado', models.CharField(blank=True, help_text='CURP del apoderado aduanal', max_length=18, null=True)), + ('importe_total', models.DecimalField(blank=True, decimal_places=2, help_text='Importe total del pedimento', max_digits=10, null=True)), + ('saldo_disponible', models.DecimalField(blank=True, decimal_places=2, help_text='Saldo disponible del pedimento', max_digits=10, null=True)), + ('importe_pedimento', models.DecimalField(blank=True, decimal_places=2, help_text='Importe del pedimento', max_digits=10, null=True)), + ('existe_expediente', models.BooleanField(default=False)), + ('remesas', models.BooleanField(default=False, help_text='Indica si el pedimento tiene remesas asociadas')), + ('numero_partidas', models.PositiveIntegerField(blank=True, default=0, help_text='Número de partidas asociadas al pedimento', null=True)), + ('numero_operacion', models.CharField(blank=True, help_text='Número de operación del pedimento', max_length=20, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Fecha de creación del registro')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Fecha de última actualización del registro')), + ('organizacion', models.ForeignKey(help_text='Organización a la que pertenece el pedimento', on_delete=django.db.models.deletion.CASCADE, related_name='pedimentos', to='organization.organizacion')), + ('tipo_operacion', models.ForeignKey(blank=True, help_text='Tipo de operación del pedimento', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pedimentos', to='customs.tipooperacion')), + ], + options={ + 'verbose_name': 'Pedimento', + 'verbose_name_plural': 'Pedimentos', + 'db_table': 'pedimento', + 'ordering': ['pedimento'], + }, + ), + migrations.CreateModel( + name='EDocument', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('numero_edocument', models.CharField(help_text='Número único del e-documento', max_length=20, unique=True)), + ('clave', models.CharField(blank=True, help_text='Clave del e-documento según la clasificación aduanera', max_length=10, null=True)), + ('cadena_original', models.TextField(blank=True, help_text='Cadena original del e-documento', null=True)), + ('sello_digital', models.TextField(blank=True, help_text='Firma digital del e-documento', null=True)), + ('descripcion', models.CharField(blank=True, help_text='Descripción del documento', max_length=200, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Fecha de creación del documento')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Fecha de última actualización del documento')), + ('organizacion', models.ForeignKey(help_text='Organización a la que pertenece el EDocument', on_delete=django.db.models.deletion.CASCADE, related_name='edocuments', to='organization.organizacion')), + ('pedimento', models.ForeignKey(help_text='Pedimento asociado al documento', on_delete=django.db.models.deletion.CASCADE, related_name='documentos', to='customs.pedimento')), + ], + options={ + 'verbose_name': 'EDocument', + 'verbose_name_plural': 'EDocuments', + 'db_table': 'edocs', + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='ProcesamientoPedimento', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('estado', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='procesamientos', to='customs.estadodeprocesamiento')), + ('organizacion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='procesamientos', to='organization.organizacion')), + ('pedimento', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='procesamientos', to='customs.pedimento')), + ('servicio', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='procesamientos', to='customs.servicio')), + ('tipo_procesamiento', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='procesamientos', to='customs.tipodeprocesamiento')), + ], + options={ + 'verbose_name': 'Procesamiento de Pedimento', + 'verbose_name_plural': 'Procesamientos de Pedimento', + 'db_table': 'procesamiento_pedimento', + 'ordering': ['created_at'], + }, + ), + ] diff --git a/api/customs/migrations/0002_remove_agenteaduanal_id_aduana_and_more.py b/api/customs/migrations/0002_remove_agenteaduanal_id_aduana_and_more.py new file mode 100644 index 0000000..78f13c1 --- /dev/null +++ b/api/customs/migrations/0002_remove_agenteaduanal_id_aduana_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.3 on 2025-07-15 14:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='agenteaduanal', + name='id_aduana', + ), + migrations.RemoveField( + model_name='agenteaduanal', + name='id_patente', + ), + migrations.DeleteModel( + name='ClavePedimento', + ), + migrations.DeleteModel( + name='Regimen', + ), + migrations.DeleteModel( + name='Aduana', + ), + migrations.DeleteModel( + name='AgenteAduanal', + ), + migrations.DeleteModel( + name='Patente', + ), + ] diff --git a/api/customs/migrations/0003_cove.py b/api/customs/migrations/0003_cove.py new file mode 100644 index 0000000..0eb29d7 --- /dev/null +++ b/api/customs/migrations/0003_cove.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.3 on 2025-07-23 22:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0002_remove_agenteaduanal_id_aduana_and_more'), + ('organization', '0002_remove_organizacion_membretado_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Cove', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('numero_cove', models.CharField(help_text='Número único de la cove', max_length=20, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Fecha de creación de la cove')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Fecha de última actualización de la cove')), + ('organizacion', models.ForeignKey(help_text='Organización a la que pertenece la cove', on_delete=django.db.models.deletion.CASCADE, related_name='coves', to='organization.organizacion')), + ('pedimento', models.ForeignKey(help_text='Pedimento asociado a la cove', on_delete=django.db.models.deletion.CASCADE, related_name='coves', to='customs.pedimento')), + ], + options={ + 'verbose_name': 'Cove', + 'verbose_name_plural': 'Coves', + 'db_table': 'coves', + 'ordering': ['created_at'], + }, + ), + ] diff --git a/api/customs/migrations/0004_pedimento_pedimento_app_alter_pedimento_pedimento.py b/api/customs/migrations/0004_pedimento_pedimento_app_alter_pedimento_pedimento.py new file mode 100644 index 0000000..ec050e6 --- /dev/null +++ b/api/customs/migrations/0004_pedimento_pedimento_app_alter_pedimento_pedimento.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.3 on 2025-08-12 19:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0003_cove'), + ] + + operations = [ + migrations.AddField( + model_name='pedimento', + name='pedimento_app', + field=models.CharField(default='', help_text='Número de pedimento en la aplicación', max_length=25), + preserve_default=False, + ), + migrations.AlterField( + model_name='pedimento', + name='pedimento', + field=models.CharField(help_text='Número de pedimento aduanal', max_length=20), + ), + ] diff --git a/api/customs/migrations/0005_remove_pedimento_pedimento_app.py b/api/customs/migrations/0005_remove_pedimento_pedimento_app.py new file mode 100644 index 0000000..740bbdb --- /dev/null +++ b/api/customs/migrations/0005_remove_pedimento_pedimento_app.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-08-12 19:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0004_pedimento_pedimento_app_alter_pedimento_pedimento'), + ] + + operations = [ + migrations.RemoveField( + model_name='pedimento', + name='pedimento_app', + ), + ] diff --git a/api/customs/migrations/0006_pedimento_pedimento_app.py b/api/customs/migrations/0006_pedimento_pedimento_app.py new file mode 100644 index 0000000..7f19fbb --- /dev/null +++ b/api/customs/migrations/0006_pedimento_pedimento_app.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.3 on 2025-08-12 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0005_remove_pedimento_pedimento_app'), + ] + + operations = [ + migrations.AddField( + model_name='pedimento', + name='pedimento_app', + field=models.CharField(default='', help_text='Número de pedimento en la aplicación', max_length=25), + preserve_default=False, + ), + ] diff --git a/api/customs/migrations/0007_regimen.py b/api/customs/migrations/0007_regimen.py new file mode 100644 index 0000000..e548df3 --- /dev/null +++ b/api/customs/migrations/0007_regimen.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.3 on 2025-08-15 18:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0006_pedimento_pedimento_app'), + ] + + operations = [ + migrations.CreateModel( + name='Regimen', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('claveped', models.CharField(max_length=4)), + ('regimenped', models.CharField(max_length=4)), + ('tipo', models.IntegerField()), + ], + options={ + 'verbose_name': 'Regimen', + 'verbose_name_plural': 'Regimenes', + 'db_table': 'regimen', + }, + ), + ] diff --git a/api/customs/migrations/0008_regimen_catalogo.py b/api/customs/migrations/0008_regimen_catalogo.py new file mode 100644 index 0000000..9fc30dd --- /dev/null +++ b/api/customs/migrations/0008_regimen_catalogo.py @@ -0,0 +1,139 @@ +from django.db import migrations + +def cargar_catalogo_regimen(apps, schema_editor): + from api.customs.models import Regimen + data = [ + (1, 'A1', 'EXD', 2), + (2, 'A1', 'IMD', 1), + (3, 'A2', 'ITE', 1), + (4, 'A3', 'IMD', 1), + (5, 'A4', 'DFI', 2), + (6, 'A4', 'DFI', 1), + (7, 'A5', 'DFI', 2), + (8, 'A5', 'DFI', 1), + (9, 'A6', 'ITR', 1), + (10, 'A7', 'ITR', 1), + (11, 'A8', 'ITE', 1), + (12, 'A9', 'ITR', 1), + (13, 'AA', 'ITE', 1), + (14, 'AD', 'ITR', 1), + (15, 'AF', 'ITE', 1), + (16, 'AF', 'ITR', 1), + (17, 'AJ', 'ETR', 2), + (18, 'AJ', 'ITR', 1), + (19, 'BA', 'ETR', 2), + (20, 'BA', 'ITR', 1), + (21, 'BB', 'EXD', 2), + (22, 'BC', 'ITR', 1), + (23, 'BD', 'ITR', 1), + (24, 'BE', 'ITR', 1), + (25, 'BF', 'ETR', 2), + (26, 'BH', 'ITR', 1), + (27, 'BI', 'ITR', 1), + (28, 'BM', 'ETE', 2), + (29, 'BO', 'ETE', 2), + (30, 'BO', 'ITR', 1), + (31, 'BP', 'ITR', 1), + (32, 'BR', 'ETR', 2), + (33, 'C1', 'IMD', 1), + (34, 'C2', 'IMD', 1), + (35, 'C3', 'IMD', 1), + (36, 'CT', 'EXD', 2), + (37, 'D1', 'EXD', 2), + (38, 'D1', 'IMD', 1), + (39, 'E1', 'ITE', 1), + (40, 'E2', 'ITR', 1), + (41, 'E3', 'ITE', 1), + (42, 'E4', 'ITR', 1), + (43, 'F2', 'DFI', 1), + (44, 'F3', 'IMD', 1), + (45, 'F4', 'EXD', 2), + (46, 'F4', 'IMD', 1), + (47, 'F5', 'IMD', 1), + (48, 'F8', 'DFI', 2), + (49, 'F8', 'DFI', 1), + (50, 'F9', 'DFI', 2), + (51, 'F9', 'DFI', 1), + (52, 'G1', 'EXD', 2), + (53, 'G1', 'IMD', 1), + (54, 'G2', 'IMD', 1), + (55, 'G6', 'EXD', 2), + (56, 'G7', 'EXD', 2), + (57, 'G8', 'RFS', 2), + (58, 'G9', 'IMD', 2), + (59, 'GC', 'EXD', 2), + (60, 'GC', 'IMD', 1), + (61, 'H1', 'EXD', 2), + (62, 'H1', 'IMD', 1), + (63, 'H8', 'EXD', 2), + (64, 'H8', 'IMD', 1), + (65, 'I1', 'EXD', 2), + (66, 'I1', 'IMD', 1), + (67, 'IN', 'ITE', 1), + (68, 'J1', 'EXD', 2), + (69, 'J2', 'EXD', 2), + (70, 'J3', 'RFE', 2), + (71, 'J4', 'RFS', 2), + (72, 'K1', 'EXD', 2), + (73, 'K1', 'IMD', 1), + (74, 'K2', 'EXD', 2), + (75, 'K3', 'EXD', 2), + (76, 'L1', 'EXD', 2), + (77, 'L1', 'IMD', 1), + (78, 'M1', 'RFE', 1), + (79, 'M2', 'RFE', 1), + (80, 'M3', 'RFS', 1), + (81, 'M4', 'RFS', 1), + (82, 'M5', 'RFS', 1), + (83, 'P1', 'IMD', 1), + (84, 'R1', 'ETE', 2), + (85, 'R1', 'ETR', 2), + (86, 'R1', 'EXD', 2), + (87, 'R1', 'IMD', 1), + (88, 'R1', 'ITE', 1), + (89, 'R1', 'ITR', 1), + (90, 'RT', 'EXD', 2), + (91, 'S2', 'EXD', 2), + (92, 'S2', 'IMD', 1), + (93, 'T1', 'EXD', 2), + (94, 'T1', 'IMD', 1), + (95, 'T3', 'TRA', 1), + (96, 'T6', 'TRA', 2), + (97, 'T7', 'TRA', 1), + (98, 'T9', 'TRA', 1), + (99, 'V1', 'EXD', 2), + (100, 'V1', 'ITE', 1), + (101, 'V1', 'ITR', 1), + (102, 'V2', 'EXD', 2), + (103, 'V2', 'IMD', 1), + (104, 'V3', 'DFI', 2), + (105, 'V3', 'DFI', 1), + (106, 'V4', 'ETR', 2), + (107, 'V4', 'ITR', 1), + (108, 'V5', 'EXD', 2), + (109, 'V5', 'IMD', 1), + (110, 'V6', 'EXD', 2), + (111, 'V6', 'IMD', 1), + (112, 'V7', 'EXD', 2), + (113, 'V7', 'ITR', 1), + (114, 'V8', 'DFI', 1), + (115, 'V8', 'EXD', 2), + (116, 'V9', 'EXD', 2), + (117, 'V9', 'IMD', 1), + (118, 'VF', 'IMD', 1), + (119, 'VU', 'IMD', 1), + (120, 'H3', 'ITE', 1), + (121, 'H3', 'ITR', 1), + ] + for id, claveped, regimenped, tipo in data: + Regimen.objects.create(id=id, claveped=claveped, regimenped=regimenped, tipo=tipo) + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0007_regimen'), + ] + + operations = [ + migrations.RunPython(cargar_catalogo_regimen), + ] diff --git a/api/customs/migrations/0009_importador.py b/api/customs/migrations/0009_importador.py new file mode 100644 index 0000000..b60c01d --- /dev/null +++ b/api/customs/migrations/0009_importador.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.3 on 2025-08-16 15:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0008_regimen_catalogo'), + ('organization', '0002_remove_organizacion_membretado_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Importador', + fields=[ + ('rfc', models.CharField(help_text='RFC del importador', max_length=13, primary_key=True, serialize=False, unique=True)), + ('nombre', models.CharField(help_text='Nombre del importador', max_length=200)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Fecha de creación del registro')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Fecha de última actualización del registro')), + ('organizacion', models.ForeignKey(help_text='Organización a la que pertenece el importador', on_delete=django.db.models.deletion.CASCADE, related_name='importadores', to='organization.organizacion')), + ], + options={ + 'verbose_name': 'Importador', + 'verbose_name_plural': 'Importadores', + 'db_table': 'importador', + 'ordering': ['rfc'], + }, + ), + ] diff --git a/api/customs/migrations/0009b_poblar_importadores.py b/api/customs/migrations/0009b_poblar_importadores.py new file mode 100644 index 0000000..88a5fa6 --- /dev/null +++ b/api/customs/migrations/0009b_poblar_importadores.py @@ -0,0 +1,21 @@ +from django.db import migrations + +def crear_importadores_desde_pedimentos(apps, schema_editor): + Pedimento = apps.get_model('customs', 'Pedimento') + Importador = apps.get_model('customs', 'Importador') + Organizacion = apps.get_model('organization', 'Organizacion') + rfcs_orgs = Pedimento.objects.values_list('contribuyente', 'organizacion_id').distinct() + for rfc, org_id in rfcs_orgs: + if rfc and not Importador.objects.filter(rfc=rfc).exists(): + organizacion = Organizacion.objects.get(id=org_id) if org_id else None + Importador.objects.create(rfc=rfc, nombre='', organizacion=organizacion) + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0009_importador'), + ] + + operations = [ + migrations.RunPython(crear_importadores_desde_pedimentos), + ] diff --git a/api/customs/migrations/0010_alter_pedimento_contribuyente.py b/api/customs/migrations/0010_alter_pedimento_contribuyente.py new file mode 100644 index 0000000..ed8cb3a --- /dev/null +++ b/api/customs/migrations/0010_alter_pedimento_contribuyente.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.3 on 2025-08-16 16:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0009b_poblar_importadores'), + ] + + operations = [ + migrations.AlterField( + model_name='pedimento', + name='contribuyente', + field=models.ForeignKey(blank=True, help_text='Contribuyente asociado al pedimento', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pedimentos', to='customs.importador'), + ), + ] diff --git a/api/customs/migrations/__init__.py b/api/customs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/customs/models.py b/api/customs/models.py new file mode 100644 index 0000000..53dfffe --- /dev/null +++ b/api/customs/models.py @@ -0,0 +1,187 @@ +import uuid +from django.db import models + +# Create your models here. + +class TipoOperacion(models.Model): + tipo = models.CharField(max_length=100) + descripcion = models.CharField(max_length=200) + + def __str__(self): + return f"{self.tipo}" + + class Meta: + verbose_name = "Tipo de Operacion" + verbose_name_plural = "Tipos de Operacion" + db_table = 'tipo_operacion' + ordering = ['tipo'] + +class Pedimento(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + pedimento = models.CharField(max_length=20, unique=False, help_text="Número de pedimento aduanal") + pedimento_app = models.CharField(max_length=25, unique=False, help_text="Número de pedimento en la aplicación") + + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='pedimentos', help_text="Organización a la que pertenece el pedimento") + + patente = models.CharField(max_length=20, blank=True, null=True, help_text="Número de patente aduanal") + aduana = models.CharField(max_length=10, blank=True, null=True, help_text="Clave de la aduana según la clasificación aduanera") + regimen = models.CharField(max_length=10, blank=True, null=True, help_text="Clave del régimen aduanero según la clasificación aduanera") + tipo_operacion = models.ForeignKey('TipoOperacion', on_delete=models.SET_NULL, blank=True, null=True, help_text="Tipo de operación del pedimento", related_name='pedimentos') + clave_pedimento = models.CharField(max_length=10, blank=True, null=True, help_text="Clave del pedimento según la clasificación aduanera") + + fecha_inicio = models.DateField(help_text="Fecha de inicio del pedimento", blank=True, null=True) + fecha_fin = models.DateField(help_text="Fecha de fin del pedimento", blank=True, null=True) + fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True) + + alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada") + + contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True) + agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal") + + curp_apoderado = models.CharField(max_length=18, blank=True, null=True, help_text="CURP del apoderado aduanal") + + importe_total = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, help_text="Importe total del pedimento") + saldo_disponible = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, help_text="Saldo disponible del pedimento") + importe_pedimento = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, help_text="Importe del pedimento") + existe_expediente = models.BooleanField(default=False) + remesas = models.BooleanField(default=False, help_text="Indica si el pedimento tiene remesas asociadas") + + numero_partidas = models.PositiveIntegerField(default=0, help_text="Número de partidas asociadas al pedimento", blank=True, null=True) + numero_operacion = models.CharField(max_length=20, blank=True, null=True, help_text="Número de operación del pedimento") + + created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del registro") + updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del registro") + + def __str__(self): + return f"{self.pedimento}" + + class Meta: + verbose_name = "Pedimento" + verbose_name_plural = "Pedimentos" + db_table = 'pedimento' + ordering = ['pedimento'] + +class EDocument(models.Model): + pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='documentos', help_text="Pedimento asociado al documento") + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='edocuments', help_text="Organización a la que pertenece el EDocument") + numero_edocument = models.CharField(max_length=20, unique=True, help_text="Número único del e-documento") + clave = models.CharField(max_length=10, blank=True, null=True, help_text="Clave del e-documento según la clasificación aduanera") + cadena_original = models.TextField(blank=True, null=True, help_text="Cadena original del e-documento") + sello_digital = models.TextField(blank=True, null=True, help_text="Firma digital del e-documento") + descripcion = models.CharField(max_length=200, blank=True, null=True, help_text="Descripción del documento") + created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del documento") + updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del documento") + + def __str__(self): + return f"{self.descripcion} - {self.pedimento.pedimento}" + + class Meta: + verbose_name = "EDocument" + verbose_name_plural = "EDocuments" + db_table = 'edocs' + ordering = ['created_at'] + +class Cove(models.Model): + pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='coves', help_text="Pedimento asociado a la cove") + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='coves', help_text="Organización a la que pertenece la cove") + numero_cove = models.CharField(max_length=20, unique=True, help_text="Número único de la cove") + created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la cove") + updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización de la cove") + + def __str__(self): + return f"{self.numero_cove} - {self.pedimento.pedimento}" + + class Meta: + verbose_name = "Cove" + verbose_name_plural = "Coves" + db_table = 'coves' + ordering = ['created_at'] + +class EstadoDeProcesamiento(models.Model): + estado = models.CharField(max_length=50) + + def __str__(self): + return self.estado + + class Meta: + verbose_name = "Estado de Procesamiento" + verbose_name_plural = "Estados de Procesamiento" + db_table = 'estado_de_procesamiento' + ordering = ['estado'] + +class TipoDeProcesamiento(models.Model): + tipo = models.CharField(max_length=50) + + def __str__(self): + return self.tipo + + class Meta: + verbose_name = "Tipo de Procesamiento" + verbose_name_plural = "Tipos de Procesamiento" + db_table = 'tipo_de_procesamiento' + ordering = ['tipo'] + +class Servicio(models.Model): + endpoint = models.CharField(max_length=100) + descripcion = models.TextField(blank=True, null=True) + hora_inicio = models.TimeField(max_length=50, blank=True, null=True) + hora_fin = models.TimeField(max_length=50, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.endpoint + + class Meta: + verbose_name = "Servicio" + verbose_name_plural = "Servicios" + db_table = 'servicio' + ordering = ['endpoint'] + +class ProcesamientoPedimento(models.Model): + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='procesamientos') + estado = models.ForeignKey(EstadoDeProcesamiento, on_delete=models.CASCADE, related_name='procesamientos') + tipo_procesamiento = models.ForeignKey(TipoDeProcesamiento, on_delete=models.CASCADE, related_name='procesamientos', blank=True, null=True) + pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='procesamientos') + servicio = models.ForeignKey(Servicio, on_delete=models.CASCADE, related_name='procesamientos', blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.pedimento.pedimento} - {self.estado.estado}" + + class Meta: + verbose_name = "Procesamiento de Pedimento" + verbose_name_plural = "Procesamientos de Pedimento" + db_table = 'procesamiento_pedimento' + ordering = ['created_at'] + +class Regimen(models.Model): + id = models.AutoField(primary_key=True) + claveped = models.CharField(max_length=4) + regimenped = models.CharField(max_length=4) + tipo = models.IntegerField() + + class Meta: + db_table = 'regimen' + verbose_name = 'Regimen' + verbose_name_plural = 'Regimenes' + + def __str__(self): + return f"{self.claveped} - {self.regimenped} - {self.tipo}" + +class Importador(models.Model): + rfc = models.CharField(primary_key=True, max_length=13, unique=True, help_text="RFC del importador") + nombre = models.CharField(max_length=200, help_text="Nombre del importador") + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='importadores', help_text="Organización a la que pertenece el importador") + created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del registro") + updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del registro") + + class Meta: + verbose_name = 'Importador' + verbose_name_plural = 'Importadores' + db_table = 'importador' + ordering = ['rfc'] + + def __str__(self): + return f"{self.rfc} - {self.nombre}" \ No newline at end of file diff --git a/api/customs/serializers.py b/api/customs/serializers.py new file mode 100644 index 0000000..078801e --- /dev/null +++ b/api/customs/serializers.py @@ -0,0 +1,96 @@ +from rest_framework import serializers +from api.customs.models import ( + Pedimento, + TipoOperacion, + ProcesamientoPedimento, + EDocument, + Cove, + Importador +) +from django.db import models +from api.record.models import Document # Asegúrate de importar el modelo Documento +from api.record.serializers import DocumentSerializer +from api.vucem.serializers import VucemSerializer + +class PedimentoSerializer(serializers.ModelSerializer): + documentos_count = serializers.SerializerMethodField() + documentos_peso_total = serializers.SerializerMethodField() + + def get_documentos_count(self, obj): + # Si obj es un dict o no tiene 'documents', devuelve 0 + if isinstance(obj, dict) or not hasattr(obj, 'documents'): + return 0 + return obj.documents.count() + + def get_documentos_peso_total(self, obj): + # Si obj es un dict o no tiene 'documents', devuelve 0 + if isinstance(obj, dict) or not hasattr(obj, 'documents'): + return 0 + return obj.documents.aggregate(total=models.Sum('size'))['total'] or 0 + class Meta: + model = Pedimento + fields = '__all__' + read_only_fields = ( + 'created_at', 'updated_at', 'organizacion', 'pedimento_app', + 'documentos_count', 'documentos_peso_total' + ) + + def to_representation(self, instance): + rep = super().to_representation(instance) + rep['documentos_count'] = self.get_documentos_count(instance) + rep['documentos_peso_total'] = self.get_documentos_peso_total(instance) + return rep + + + +class TipoOperacionSerializer(serializers.ModelSerializer): + class Meta: + model = TipoOperacion + fields = '__all__' + +class ProcesamientoPedimentoSerializer(serializers.ModelSerializer): + + organizacion = serializers.PrimaryKeyRelatedField(queryset=ProcesamientoPedimento._meta.get_field('organizacion').related_model.objects.all(), required=False) + organizacion_name = serializers.CharField(source='organizacion.nombre', read_only=True) + + class Meta: + model = ProcesamientoPedimento + fields = '__all__' + read_only_fields = ('created_at', 'updated_at') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get('request') + # Si no es superusuario, hacer organizacion read_only + if request and hasattr(request, 'user') and not request.user.is_superuser: + self.fields['organizacion'].read_only = True + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation['pedimento'] = PedimentoSerializer(instance.pedimento).data + return representation + +class EDocumentSerializer(serializers.ModelSerializer): + class Meta: + model = EDocument + fields = '__all__' + read_only_fields = ('created_at', 'updated_at') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Si no es superusuario, hacer organizacion read_only + request = self.context.get('request') + if request and hasattr(request, 'user') and not request.user.is_superuser: + self.fields['organizacion'].read_only = True + +class CoveSerializer(serializers.ModelSerializer): + class Meta: + model = Cove + fields = '__all__' + read_only_fields = ('created_at', 'updated_at') + +class ImportadorSerializer(serializers.ModelSerializer): + class Meta: + model = Importador + fields = '__all__' + read_only_fields = ('created_at', 'updated_at') \ No newline at end of file diff --git a/api/customs/signals/__init__.py b/api/customs/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/customs/signals/procesamiento.py b/api/customs/signals/procesamiento.py new file mode 100644 index 0000000..1ac8a3c --- /dev/null +++ b/api/customs/signals/procesamiento.py @@ -0,0 +1,59 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.db import transaction +from time import sleep + +from api.customs.models import Pedimento, ProcesamientoPedimento, Cove, EDocument +from api.customs.tasks.internal_services import ( + crear_procesamiento_remesa, + crear_procesamiento_partida, + crear_procesamiento_cove, + crear_procesamiento_acuse_cove, + crear_procesamiento_acuse, + crear_procesamiento_edocument +) +from api.customs.tasks.microservice import ( + ejecutar_pedimento_completo, + procesar_pedimento_completo_individual + +) + +@receiver(post_save, sender=Pedimento) +def trigger_celery_task_on_create(sender, instance, created, **kwargs): + if created: + procesar_pedimento_completo_individual.apply_async(args=[instance.id, instance.organizacion.id]) + +@receiver(post_save, sender=Pedimento) +def trigger_celery_task_on_update(sender, instance, created,**kwargs): + if not created: + import logging + logger = logging.getLogger('api.customs.async_operations') + logger.info(f"Pedimento actualizado: {instance.id}, verificando servicios a crear...") + sleep(4) + def enqueue_tasks(): + if instance.remesas: + logger.info(f"Creando proceso de remesas para pedimento {instance.id}") + crear_procesamiento_remesa.apply_async(args=[str(instance.id)]) + if hasattr(instance, 'numero_partidas') and instance.numero_partidas and instance.numero_partidas > 0: + logger.info(f"Creando proceso de partida para pedimento {instance.id}") + crear_procesamiento_partida.apply_async(args=[str(instance.id)]) + + transaction.on_commit(enqueue_tasks) + +@receiver(post_save, sender=Cove) +def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs): + if created: + import logging + logger = logging.getLogger('api.customs.async_operations') + logger.info(f"Cove creado: {instance.id}, creando procesamiento...") + crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)]) + crear_procesamiento_acuse_cove.apply_async(args=[str(instance.pedimento.id)]) + +@receiver(post_save, sender=EDocument) +def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs): + if created: + import logging + logger = logging.getLogger('api.customs.async_operations') + logger.info(f"EDocument creado: {instance.id}, creando procesamiento...") + crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)]) + crear_procesamiento_acuse.apply_async(args=[str(instance.pedimento.id)]) \ No newline at end of file diff --git a/api/customs/tasks/__init__.py b/api/customs/tasks/__init__.py new file mode 100644 index 0000000..6157c33 --- /dev/null +++ b/api/customs/tasks/__init__.py @@ -0,0 +1,2 @@ +from .microservice import * +from .internal_services import * \ No newline at end of file diff --git a/api/customs/tasks/auditoria.py b/api/customs/tasks/auditoria.py new file mode 100644 index 0000000..6ed98d0 --- /dev/null +++ b/api/customs/tasks/auditoria.py @@ -0,0 +1,78 @@ +from celery import shared_task, group +from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument +from core.utils import xml_controller +import requests +from core.utils import xml_remesas_controller + +def obtener_pedimentos(organizacion_id): + return Pedimento.objects.filter(organizacion_id=organizacion_id) + +def extraer_coves(pedimento): + remesas = pedimento.documents.filter(document_type=3).first() + with open(f'./media/{remesas.archivo}', 'r') as f: + xml_content = f.read() + + xml_data = xml_remesas_controller.extract_remesas(xml_content) + + return xml_data + +@shared_task +def auditar_procesamiento_remesas(organizacion_id): + pedimentos = obtener_pedimentos(organizacion_id) + + for pedimento in pedimentos: + if pedimento.remesas: + # Tipo 3: Remesa + if not pedimento.documents.filter(document_type=3).exists(): + ProcesamientoPedimento.objects.get_or_create( + pedimento=pedimento, + servicio_id=5, # ID del servicio de remesas + organizacion=organizacion_id + ) + else: + xml_data = extraer_coves(pedimento) + if xml_data: + for remesa in xml_data: + Cove.objects.get_or_create( + pedimento=pedimento, + numero_cove=remesa.get('remesaSA'), + organizacion=organizacion_id + ) + + +@shared_task +def auditar_partidas(organizacion_id): + pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) + for pedimento in pedimentos: + partidas_descargadas = pedimento.documents.filter(document_type=1) + + partidas = {str(documento.archivo).split('_')[-1].split('.')[0]: documento.archivo for documento in partidas_descargadas} + partidas_faltantes = [] + + for i in range(1, pedimento.numero_partidas + 1): + if str(i) not in partidas.keys(): + partidas_faltantes.append(i) + # crear servicio individual para cada partida faltante en microservicios + + +@shared_task +def auditar_coves(organizacion_id): + # crear servicio individual para cada cove faltante en microservicios + pass + +@shared_task +def auditar_edocuments(organizacion_id): + # crear servicio individual para cada Edocument faltante en microservicios + pass + +@shared_task +def auditar_acuse_coves(organizacion_id): + # crear servicio individual para cada cove faltante en microservicios + + pass + +@shared_task +def auditar_acuse_edocuments(organizacion_id): + # crear servicio individual para cada acuse de edocument faltante en microservicios + pass + diff --git a/api/customs/tasks/internal_services.py b/api/customs/tasks/internal_services.py new file mode 100644 index 0000000..961c399 --- /dev/null +++ b/api/customs/tasks/internal_services.py @@ -0,0 +1,220 @@ +from celery import shared_task, group +from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument +from core.utils import xml_controller + +@shared_task +def crear_procesamiento_remesa(pedimento_id): + import logging + logger = logging.getLogger('api.customs.async_operations') + pedimento = Pedimento.objects.get(id=pedimento_id) + logger.info(f"[TAREA] crear_procesamiento_remesa para pedimento {pedimento_id}") + if pedimento.remesas: + existe = ProcesamientoPedimento.objects.filter( + pedimento=pedimento, + servicio_id=5, # ID del servicio de remesas + organizacion=pedimento.organizacion, + estado_id__in=[1, 2, 3, 4] + ).exists() + if not existe: + logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}") + ProcesamientoPedimento.objects.create( + pedimento=pedimento, + estado_id=1, # Estado "pendiente" + servicio_id=5, + organizacion=pedimento.organizacion + ) + +@shared_task +def crear_procesamiento_partida(pedimento_id): + import logging + logger = logging.getLogger('api.customs.async_operations') + pedimento = Pedimento.objects.get(id=pedimento_id) + logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}") + existe = ProcesamientoPedimento.objects.filter( + pedimento=pedimento, + servicio_id=4, # ID del servicio de partidas + organizacion=pedimento.organizacion, + estado_id__in=[1, 2, 3, 4] + ).exists() + if not existe: + logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}") + ProcesamientoPedimento.objects.create( + pedimento=pedimento, + estado_id=1, # Estado "pendiente" + servicio_id=4, + organizacion=pedimento.organizacion + ) + +@shared_task +def crear_procesamiento_cove(pedimento_id): + import logging + logger = logging.getLogger('api.customs.async_operations') + pedimento = Pedimento.objects.get(id=pedimento_id) + logger.info(f"[TAREA] crear_procesamiento_cove para pedimento {pedimento_id}") + if pedimento.coves.exists(): + existe = ProcesamientoPedimento.objects.filter( + pedimento=pedimento, + servicio_id=8, # ID del servicio de Coves + organizacion=pedimento.organizacion, + estado_id__in=[1, 2, 3, 4] + ).exists() + if not existe: + logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}") + ProcesamientoPedimento.objects.create( + pedimento=pedimento, + estado_id=1, # Estado "pendiente" + servicio_id=8, + organizacion=pedimento.organizacion + ) + +@shared_task +def crear_procesamiento_acuse(pedimento_id): + import logging + logger = logging.getLogger('api.customs.async_operations') + pedimento = Pedimento.objects.get(id=pedimento_id) + logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {pedimento_id}") + if pedimento.coves.exists(): + existe = ProcesamientoPedimento.objects.filter( + pedimento=pedimento, + servicio_id=6, # ID del servicio de Acuse Cove + organizacion=pedimento.organizacion, + estado_id__in=[1, 2, 3, 4] + ).exists() + if not existe: + logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}") + ProcesamientoPedimento.objects.create( + pedimento=pedimento, + estado_id=1, # Estado "pendiente" + servicio_id=6, + organizacion=pedimento.organizacion + ) + +@shared_task +def crear_procesamiento_acuse_cove(pedimento_id): + import logging + logger = logging.getLogger('api.customs.async_operations') + pedimento = Pedimento.objects.get(id=pedimento_id) + logger.info(f"[TAREA] crear_procesamiento_acuse_cove para pedimento {pedimento_id}") + if pedimento.coves.exists(): + existe = ProcesamientoPedimento.objects.filter( + pedimento=pedimento, + servicio_id=9, # ID del servicio de Acuse Cove + organizacion=pedimento.organizacion, + estado_id__in=[1, 2, 3, 4] + ).exists() + if not existe: + logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}") + ProcesamientoPedimento.objects.create( + pedimento=pedimento, + estado_id=1, # Estado "pendiente" + servicio_id=9, + organizacion=pedimento.organizacion + ) + +@shared_task +def crear_procesamiento_edocument(pedimento_id): + import logging + logger = logging.getLogger('api.customs.async_operations') + pedimento = Pedimento.objects.get(id=pedimento_id) + logger.info(f"[TAREA] crear_procesamiento_edocument para pedimento {pedimento_id}") + if pedimento.documentos.exists(): + existe = ProcesamientoPedimento.objects.filter( + pedimento=pedimento, + servicio_id=7, # ID del servicio de EDocument + organizacion=pedimento.organizacion, + estado_id__in=[1, 2, 3, 4] + ).exists() + if not existe: + logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}") + ProcesamientoPedimento.objects.create( + pedimento=pedimento, + estado_id=1, # Estado "pendiente" + servicio_id=7, + organizacion=pedimento.organizacion + ) + +@shared_task +def crear_procesamiento_pedimento_completo(organizacion_id): + import logging + logger = logging.getLogger('api.customs.async_operations') + pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) + for pedimento in pedimentos: + logger.info(f"[TAREA] crear_procesamiento_pedimento_completo para pedimento {pedimento.id}") + existe = ProcesamientoPedimento.objects.filter( + pedimento=pedimento, + servicio_id=3, # ID del servicio de Pedimento Completo + organizacion=pedimento.organizacion, + estado_id__in=[1, 2, 3, 4] + ).exists() + if not existe: + logger.info(f"[TAREA] ProcesamientoPedimento pedimento_completo creado para pedimento {pedimento.id}") + ProcesamientoPedimento.objects.create( + pedimento=pedimento, + estado_id=1, # Estado "pendiente" + servicio_id=3, + organizacion=pedimento.organizacion + ) + +@shared_task +def crear_servicios(organizacion_id): + pedimentos = Pedimento.objects.filter(organizacion=organizacion_id) + for pedimento in pedimentos: + crear_procesamiento_remesa.apply_async(args=[str(pedimento.id)]) + crear_procesamiento_partida.apply_async(args=[str(pedimento.id)]) + crear_procesamiento_cove.apply_async(args=[str(pedimento.id)]) + crear_procesamiento_acuse.apply_async(args=[str(pedimento.id)]) + crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)]) + crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)]) + +@shared_task +def auditar_pedimento(organizacion_id): + + pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) + for pedimento in pedimentos: + pc = pedimento.documents.filter(document_type__id=2).first() + if pc: + with open(f'./media/{pc.archivo}', 'r') as f: + xml_content = f.read() + + xml_data = xml_controller.extract_data(xml_content) + + pedimento.numero_operacion = xml_data.get('numero_operacion') + pedimento.curp_apoderado = xml_data.get('curp_apoderado') + pedimento.agente_aduanal = xml_data.get('agente_aduanal') + pedimento.numero_partidas = xml_data.get('numero_partidas') + pedimento.remesas = xml_data.get('remesas') + pedimento.tipo_operacion__id = xml_data.get('tipo_operacion') + pedimento.save() + + for edoc in xml_data.get('edocuments', []): + EDocument.objects.get_or_create( + pedimento=pedimento, + organizacion=pedimento.organizacion, + clave=edoc.get('clave'), + descripcion=edoc.get('descripcion'), + numero_edocument=edoc.get('complemento1') + ) + + from django.db import IntegrityError + try: + for cove in xml_data.get('coves', []): + try: + Cove.objects.get_or_create( + pedimento=pedimento, + organizacion=pedimento.organizacion, + numero_cove=cove + ) + except IntegrityError: + # Si ya existe por unique, recupera el objeto existente + Cove.objects.get(numero_cove=cove) + except: + # Si ya existe por unique, recupera el objeto existente + pass + +@shared_task +def crear_todos_los_servicios(): + from organization.models import Organizacion + organizaciones = Organizacion.objects.all() + for org in organizaciones: + crear_procesamiento_pedimento_completo.apply_async(args=[str(org.id)]) + crear_servicios.apply_async(args=[str(org.id)]) \ No newline at end of file diff --git a/api/customs/tasks/microservice.py b/api/customs/tasks/microservice.py new file mode 100644 index 0000000..7fdb631 --- /dev/null +++ b/api/customs/tasks/microservice.py @@ -0,0 +1,213 @@ + +from celery import shared_task, group +from api.customs.models import ProcesamientoPedimento +import requests +from config.settings import SERVICE_API_URL +from datetime import datetime + + +# =================== +# Pedimento Completo +# =================== +@shared_task +def procesar_pedimento_completo_individual(pedimento_id, organizacion_id): + response = requests.post( + f"{SERVICE_API_URL}/async/services/pedimento_completo", + json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)} + ) + if response.status_code == 200: + print(f"Pedimento {pedimento_id} procesado correctamente.") + else: + print(f"Error al procesar el pedimento {pedimento_id}: {response.status_code} - {response.text}") + print(f"Disparando evento para procesamiento {pedimento_id}") + +@shared_task +def ejecutar_pedimento_completo(): + pendientes = ProcesamientoPedimento.objects.filter(estado=1, servicio=3) + batch_size = 20 + ids = list(pendientes.values_list('pedimento_id', 'organizacion_id')) + for i in range(0, len(ids), batch_size): + batch = ids[i:i+batch_size] + job = group(procesar_pedimento_completo_individual.s(ped_id, org_id) for ped_id, org_id in batch) + job.apply_async() + # Validar horario permitido (5:00 a 22:00) + ahora = datetime.now().time() + if (ahora < datetime.strptime('05:00', '%H:%M').time()) or (ahora >= datetime.strptime('22:00', '%H:%M').time()): + print('ejecutar_pedimento_completo: fuera de horario permitido (5:00-22:00). Abortando.') + return + +# =================== +# Partidas +# =================== +@shared_task +def procesar_partida_individual(pedimento_id, organizacion_id): + response = requests.post( + f"{SERVICE_API_URL}/async/services/partidas", + json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)} + ) + if response.status_code == 200: + print(f"Partidas del pedimento {pedimento_id} procesadas correctamente.") + else: + print(f"Error al procesar partidas del pedimento {pedimento_id}: {response.status_code} - {response.text}") + print(f"Disparando evento para procesamiento {pedimento_id}") + +@shared_task +def ejecutar_partidas_pedimento(): + pendientes = ProcesamientoPedimento.objects.filter(estado=1, servicio=4) + batch_size = 20 + ids = list(pendientes.values_list('pedimento_id', 'organizacion_id')) + for i in range(0, len(ids), batch_size): + batch = ids[i:i+batch_size] + job = group(procesar_partida_individual.s(ped_id, org_id) for ped_id, org_id in batch) + job.apply_async() + # Validar horario permitido (5:00 a 22:00) + ahora = datetime.now().time() + if (ahora < datetime.strptime('05:00', '%H:%M').time()) or (ahora >= datetime.strptime('22:00', '%H:%M').time()): + print('ejecutar_partidas_pedimento: fuera de horario permitido (5:00-22:00). Abortando.') + return + +# =================== +# Remesas +# =================== +@shared_task +def procesar_remesa_individual(pedimento_id, organizacion_id): + response = requests.post( + f"{SERVICE_API_URL}/async/services/remesas", + json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)} + ) + if response.status_code == 200: + print(f"Remesas del pedimento {pedimento_id} procesadas correctamente.") + else: + print(f"Error al procesar remesas del pedimento {pedimento_id}: {response.status_code} - {response.text}") + print(f"Disparando evento para procesamiento {pedimento_id}") + +@shared_task +def ejecutar_remesas(): + pendientes = ProcesamientoPedimento.objects.filter(estado=1, servicio=5) + batch_size = 20 + ids = list(pendientes.values_list('pedimento_id', 'organizacion_id')) + for i in range(0, len(ids), batch_size): + batch = ids[i:i+batch_size] + job = group(procesar_remesa_individual.s(ped_id, org_id) for ped_id, org_id in batch) + job.apply_async() + # Validar horario permitido (5:00 a 22:00) + ahora = datetime.now().time() + if (ahora < datetime.strptime('05:00', '%H:%M').time()) or (ahora >= datetime.strptime('22:00', '%H:%M').time()): + print('ejecutar_remesas: fuera de horario permitido (5:00-22:00). Abortando.') + return + +# =================== +# Acuses +# =================== +@shared_task +def procesar_acuse_individual(pedimento_id, organizacion_id): + response = requests.post( + f"{SERVICE_API_URL}/async/services/acuse", + json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)} + ) + if response.status_code == 200: + print(f"Acuses del pedimento {pedimento_id} procesadas correctamente.") + else: + print(f"Error al procesar Acuses del pedimento {pedimento_id}: {response.status_code} - {response.text}") + print(f"Disparando evento para procesamiento {pedimento_id}") + +@shared_task +def ejecutar_acuse(): + pendientes = ProcesamientoPedimento.objects.filter(estado=1, servicio=6) + batch_size = 20 + ids = list(pendientes.values_list('pedimento_id', 'organizacion_id')) + for i in range(0, len(ids), batch_size): + batch = ids[i:i+batch_size] + job = group(procesar_acuse_individual.s(ped_id, org_id) for ped_id, org_id in batch) + job.apply_async() + # Validar horario permitido (5:00 a 22:00) + ahora = datetime.now().time() + if (ahora < datetime.strptime('05:00', '%H:%M').time()) or (ahora >= datetime.strptime('22:00', '%H:%M').time()): + print('ejecutar_acuse: fuera de horario permitido (5:00-22:00). Abortando.') + return + +# =================== +# Edocuments +# =================== +@shared_task +def procesar_edoc_individual(pedimento_id, organizacion_id): + response = requests.post( + f"{SERVICE_API_URL}/async/services/edocument", + json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)} + ) + if response.status_code == 200: + print(f"Edocuments del pedimento {pedimento_id} procesadas correctamente.") + else: + print(f"Error al procesar Edocuments del pedimento {pedimento_id}: {response.status_code} - {response.text}") + print(f"Disparando evento para procesamiento {pedimento_id}") + +@shared_task +def ejecutar_edocs(): + pendientes = ProcesamientoPedimento.objects.filter(estado=1, servicio=7) + batch_size = 20 + ids = list(pendientes.values_list('pedimento_id', 'organizacion_id')) + for i in range(0, len(ids), batch_size): + batch = ids[i:i+batch_size] + job = group(procesar_edoc_individual.s(ped_id, org_id) for ped_id, org_id in batch) + job.apply_async() + +# =================== +# Coves +# =================== +@shared_task +def procesar_cove_individual(pedimento_id, organizacion_id): + response = requests.post( + f"{SERVICE_API_URL}/async/services/coves", + json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)} + ) + if response.status_code == 200: + print(f"Coves del pedimento {pedimento_id} procesadas correctamente.") + else: + print(f"Error al procesar Coves del pedimento {pedimento_id}: {response.status_code} - {response.text}") + print(f"Disparando evento para procesamiento {pedimento_id}") + +@shared_task +def ejecutar_coves(): + pendientes = ProcesamientoPedimento.objects.filter(estado=1, servicio=8) + batch_size = 20 + ids = list(pendientes.values_list('pedimento_id', 'organizacion_id')) + for i in range(0, len(ids), batch_size): + batch = ids[i:i+batch_size] + job = group(procesar_cove_individual.s(ped_id, org_id) for ped_id, org_id in batch) + job.apply_async() + # Validar horario permitido (5:00 a 22:00) + ahora = datetime.now().time() + if (ahora < datetime.strptime('05:00', '%H:%M').time()) or (ahora >= datetime.strptime('22:00', '%H:%M').time()): + print('ejecutar_coves: fuera de horario permitido (5:00-22:00). Abortando.') + return + +# =================== +# Acuse Cove +# =================== +@shared_task +def procesar_acuse_cove_individual(pedimento_id, organizacion_id): + response = requests.post( + f"{SERVICE_API_URL}/async/services/acuse-cove", + json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)} + ) + if response.status_code == 200: + print(f"Coves del pedimento {pedimento_id} procesadas correctamente.") + else: + print(f"Error al procesar Coves del pedimento {pedimento_id}: {response.status_code} - {response.text}") + print(f"Disparando evento para procesamiento {pedimento_id}") + +@shared_task +def ejecutar_acuseCoves(): + pendientes = ProcesamientoPedimento.objects.filter(estado=1, servicio=9) + batch_size = 20 + ids = list(pendientes.values_list('pedimento_id', 'organizacion_id')) + for i in range(0, len(ids), batch_size): + batch = ids[i:i+batch_size] + job = group(procesar_acuse_cove_individual.s(ped_id, org_id) for ped_id, org_id in batch) + job.apply_async() + # Validar horario permitido (5:00 a 22:00) + ahora = datetime.now().time() + if (ahora < datetime.strptime('05:00', '%H:%M').time()) or (ahora >= datetime.strptime('22:00', '%H:%M').time()): + print('ejecutar_acuseCoves: fuera de horario permitido (5:00-22:00). Abortando.') + return + diff --git a/api/customs/tests.py b/api/customs/tests.py new file mode 100644 index 0000000..bcbbebf --- /dev/null +++ b/api/customs/tests.py @@ -0,0 +1,77 @@ + +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from api.organization.models import Organizacion +from .models import Pedimento, TipoOperacion, ProcesamientoPedimento, EDocument + +User = get_user_model() + + +class CustomsViewsTests(APITestCase): + def setUp(self): + self.org = Organizacion.objects.create(nombre="OrgTest", is_active=True, is_verified=True) + self.org2 = Organizacion.objects.create(nombre="OrgTest2", is_active=True, is_verified=True) + self.admin = User.objects.create_user(username="admin", password="adminpass", organizacion=self.org) + self.admin.groups.create(name="admin") + self.superuser = User.objects.create_superuser(username="superuser", password="superpass") + self.importador = User.objects.create_user(username="importador", password="importpass", organizacion=self.org2, is_importador=True, rfc="RFC123456789") + self.importador.groups.create(name="importador") + self.client = APIClient() + + def test_admin_sees_only_own_pedimentos(self): + from .models import Pedimento + p1 = Pedimento.objects.create(pedimento="P1", organizacion=self.org) + p2 = Pedimento.objects.create(pedimento="P2", organizacion=self.org2) + self.client.force_authenticate(user=self.admin) + url = reverse('Pedimento-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + pedimentos = [p['pedimento'] for p in response.data] + self.assertIn("P1", pedimentos) + self.assertNotIn("P2", pedimentos) + + def test_superuser_sees_all_pedimentos(self): + from .models import Pedimento + p1 = Pedimento.objects.create(pedimento="P1", organizacion=self.org) + p2 = Pedimento.objects.create(pedimento="P2", organizacion=self.org2) + self.client.force_authenticate(user=self.superuser) + url = reverse('Pedimento-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + pedimentos = [p['pedimento'] for p in response.data] + self.assertIn("P1", pedimentos) + self.assertIn("P2", pedimentos) + + def test_importador_cannot_create_pedimento(self): + self.client.force_authenticate(user=self.importador) + url = reverse('Pedimento-list') + data = { + "pedimento": "P3", + "patente": "1234", + "aduana": "001", + "regimen": "A1", + "clave_pedimento": "A1", + "contribuyente": "ImportadorTest" + } + response = self.client.post(url, data) + self.assertNotIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) + + def test_list_tipos_operacion(self): + url = reverse('TipoOperacion-list') + self.client.force_authenticate(user=self.admin) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_list_procesamientos(self): + url = reverse('ProcesamientoPedimento-list') + self.client.force_authenticate(user=self.admin) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_list_edocuments(self): + url = reverse('EDocument-list') + self.client.force_authenticate(user=self.admin) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/api/customs/urls.py b/api/customs/urls.py new file mode 100644 index 0000000..f1eaf9d --- /dev/null +++ b/api/customs/urls.py @@ -0,0 +1,34 @@ +# This file defines the URL patterns for the customs app in a Django project. +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +# import necessary viewsets +from .views import ( + ViewSetPedimento, + ViewSetTipoOperacion, + ViewSetProcesamientoPedimento, + ViewSetEDocument, + ViewSetCove, + ImportadorViewSet +) +# from .views import YourViewSet # Import your viewsets here + +router = DefaultRouter() + +# Register your viewsets with the router here +# Example: +# from .views import MyViewSet +# router.register(r'myviewset', MyViewSet, basename='myviewset') + +router.register(r'pedimentos', ViewSetPedimento, basename='Pedimento') +router.register(r'tiposoperacion', ViewSetTipoOperacion, basename='TipoOperacion') +router.register(r'procesamientopedimentos', ViewSetProcesamientoPedimento, basename='ProcesamientoPedimento') +router.register(r'edocuments', ViewSetEDocument, basename='EDocument') +router.register(r'coves', ViewSetCove, basename='Cove') +router.register(r'importadores', ImportadorViewSet, basename='Importador') + +# Import your viewsets here + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/api/customs/views.py b/api/customs/views.py new file mode 100644 index 0000000..840690d --- /dev/null +++ b/api/customs/views.py @@ -0,0 +1,490 @@ +from config.settings import SERVICE_API_URL +from django.shortcuts import render +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.pagination import PageNumberPagination +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.exceptions import PermissionDenied +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter, OrderingFilter +from core.permissions import ( + IsSameOrganization, + IsSameOrganizationDeveloper, + IsSameOrganizationAndAdmin, + IsSuperUser +) +from api.customs.models import ( + Pedimento, + TipoOperacion, + ProcesamientoPedimento, + EDocument, + Cove, + Importador +) +from api.customs.serializers import ( + PedimentoSerializer, + TipoOperacionSerializer, + ProcesamientoPedimentoSerializer, + EDocumentSerializer, + CoveSerializer, + ImportadorSerializer + +) +from api.logger.mixins import LoggingMixin +from mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin +import requests + + + +class CustomPagination(PageNumberPagination): + + """ + Paginación personalizada con parámetros flexibles + - Si no se especifica page_size, devuelve todos los resultados (sin paginación) + - Si se especifica page_size, usa paginación normal + """ + page_size = None # Sin paginación por defecto + page_size_query_param = 'page_size' + max_page_size = 10000 # Límite máximo de seguridad + page_query_param = 'page' + + def paginate_queryset(self, queryset, request, view=None): + """ + Si no se especifica page_size en los parámetros, devolver None (sin paginación) + Si se especifica, usar paginación normal + """ + # Verificar si se especificó page_size en la query + if self.page_size_query_param not in request.query_params: + # No hay page_size, devolver None para indicar "sin paginación" + return None + + # Hay page_size, usar paginación normal + try: + page_size = int(request.query_params[self.page_size_query_param]) + if page_size <= 0: + return None + # Establecer el page_size temporalmente para esta request + self.page_size = min(page_size, self.max_page_size) + except (ValueError, TypeError): + return None + + return super().paginate_queryset(queryset, request, view) + +class PedimentoPagination(PageNumberPagination): + + """ + Paginación personalizada con parámetros flexibles + - Si no se especifica page_size, devuelve todos los resultados (sin paginación) + - Si se especifica page_size, usa paginación normal + """ + page_size = None # Sin paginación por defecto + page_size_query_param = 'page_size' + max_page_size = 1000 # Límite máximo de seguridad + page_query_param = 'page' + + def paginate_queryset(self, queryset, request, view=None): + """ + Si no se especifica page_size en los parámetros, devolver None (sin paginación) + Si se especifica, usar paginación normal + """ + # Verificar si se especificó page_size en la query + if self.page_size_query_param not in request.query_params: + # No hay page_size, devolver None para indicar "sin paginación" + return None + + # Hay page_size, usar paginación normal + try: + page_size = int(request.query_params[self.page_size_query_param]) + if page_size <= 0: + return None + # Establecer el page_size temporalmente para esta request + self.page_size = min(page_size, self.max_page_size) + except (ValueError, TypeError): + return None + + return super().paginate_queryset(queryset, request, view) + +# Create your views here. +class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): # Pendiente de permisos de creacion + """ + ViewSet for Pedimento model. + Soporta paginación, filtros y búsqueda. + + Parámetros disponibles: + - page: Número de página (solo si se especifica page_size) + - page_size: Elementos por página (si NO se especifica, devuelve TODOS los resultados) + - search: Búsqueda en pedimento, contribuyente, agente_aduanal + - pedimento: Filtro por número de pedimento + - existe_expediente: Filtro por expediente (True/False) + - contribuyente: Filtro por contribuyente + - curp_apoderado: Filtro por curp del apoderado + - fecha_pago: Filtro por fecha de pago (YYYY-MM-DD) + - patente: Filtro por patente + - aduana: Filtro por aduana + - tipo_operacion: Filtro por tipo de operación + - clave_pedimento: Filtro por clave de pedimento + - ordering: Ordenar por campo (ej: -created_at, pedimento) + + Ejemplos: + - /pedimentos/ → Devuelve TODOS los pedimentos + - /pedimentos/?page_size=10 → Devuelve los primeros 10 + - /pedimentos/?page_size=10&page=2 → Devuelve los pedimentos 11-20 + - /pedimentos/?pedimento=12345678 → Filtra por número de pedimento + - /pedimentos/?existe_expediente=true → Filtra por expediente existente + - /pedimentos/?contribuyente=EMPRESA → Filtra por contribuyente + - /pedimentos/?curp_apoderado=XXXX → Filtra por curp apoderado + - /pedimentos/?fecha_pago=2025-07-18 → Filtra por fecha de pago + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + serializer_class = PedimentoSerializer + pagination_class = PedimentoPagination + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + model = Pedimento + + filterset_fields = ['patente', 'aduana', 'tipo_operacion', 'clave_pedimento', 'pedimento', 'existe_expediente', 'contribuyente', 'curp_apoderado', 'fecha_pago', 'pedimento_app'] + search_fields = ['pedimento', 'pedimento_app', 'agente_aduanal', 'clave_pedimento'] + + def get_queryset(self): + return self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador + + def perform_create(self, serializer): + """ + Asigna automáticamente la organización del usuario autenticado al crear un pedimento. + """ + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("Usuario no autenticado o sin organización") + data = serializer.validated_data + if not data.get('pedimento_app'): + fecha_pago = data.get('fecha_pago') + aduana = data.get('aduana') + patente = data.get('patente') + pedimento = data.get('pedimento') + if fecha_pago and aduana and patente and pedimento: + pedimento_app = f"{str(fecha_pago.year)[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento).zfill(7)[-7:]}" + serializer.save(organizacion=self.request.user.organizacion, pedimento_app=pedimento_app) + return + + try: + # Usar el nombre del servicio de Docker Compose en lugar de localhost + response = requests.request('POST', f'{SERVICE_API_URL}/services/pedimento', params={}, + json={ + 'estado': 1, + 'servicio': 3, + 'tipo_procesamiento': 2, + 'pedimento': str(serializer.instance.id), + 'organizacion': str(self.request.user.organizacion.id), + }, + timeout=10 + ) + + # Verificar si la respuesta fue exitosa + if response.status_code == 200: + print(f"✅ Servicio FastAPI ejecutado exitosamente: {response.status_code}") + print(f"📄 Respuesta: {response.json()}") + elif response.status_code == 201: + print(f"✅ Recurso creado exitosamente en FastAPI: {response.status_code}") + print(f"📄 Respuesta: {response.json()}") + else: + print(f"⚠️ Servicio FastAPI respondió con error: {response.status_code}") + print(f"📄 Respuesta: {response.text}") + + except requests.exceptions.ConnectionError as e: + print(f"❌ No se pudo conectar al servicio FastAPI: {e}") + print(f"🔧 Verifica que el servicio FastAPI esté corriendo en {SERVICE_API_URL}") + except requests.exceptions.Timeout as e: + print(f"⏰ Timeout al conectar con el servicio FastAPI: {e}") + except requests.exceptions.RequestException as e: + print(f"🚨 Error de request al servicio FastAPI: {e}") + except Exception as e: + print(f"💥 Error inesperado al llamar al servicio FastAPI: {e}") + + my_tags = ['Pedimentos'] + +class ViewSetTipoOperacion(LoggingMixin, viewsets.ModelViewSet): + """ + ViewSet for TipoOperacion model. + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + + queryset = TipoOperacion.objects.all() + serializer_class = TipoOperacionSerializer + pagination_class = CustomPagination + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ['tipo'] + search_fields = ['tipo', 'descripcion'] + ordering_fields = ['tipo', 'descripcion'] + ordering = ['tipo'] + + my_tags = ['Tipos_Operacion'] + + def perform_create(self, serializer): + """ + Asigna automáticamente la organización del usuario autenticado al crear un tipo de operación. + """ + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("Usuario no autenticado o sin organización") + # Solo el supoerusuario puede crear tipos de operación + if not self.request.user.is_superuser: + raise PermissionDenied("Solo los superusuarios pueden crear tipos de operación") + + serializer.save(organizacion=self.request.user.organizacion) + + def perform_update(self, serializer): + """ + Solo el superusuario puede actualizar tipos de operación. + """ + if not self.request.user.is_superuser: + raise PermissionDenied("Solo los superusuarios pueden actualizar tipos de operación") + + serializer.save() + +class ViewSetProcesamientoPedimento(viewsets.ModelViewSet, ProcesosPorOrganizacionMixin): + + """ + ViewSet for ProcesamientoPedimento model. + Soporta paginación, filtros y búsqueda. + + Parámetros disponibles: + - page: Número de página (solo si se especifica page_size) + - page_size: Elementos por página (si NO se especifica, devuelve TODOS los resultados) + - pedimento: Filtro por pedimento + - estado: Filtro por estado + - servicio: Filtro por servicio + - tipo_procesamiento: Filtro por tipo de procesamiento + - ordering: Ordenar por campo (ej: -created_at, -updated_at) + + Ejemplos: + - /procesamientopedimentos/ → Devuelve TODOS los procesamientos + - /procesamientopedimentos/?page_size=5 → Devuelve los primeros 5 + """ + permission_classes = [IsAuthenticated, IsSuperUser | IsSameOrganizationDeveloper ] + serializer_class = ProcesamientoPedimentoSerializer + pagination_class = CustomPagination + model = ProcesamientoPedimento + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = { + 'pedimento': ['exact'], + 'pedimento__pedimento_app': ['exact', 'icontains'], + 'estado': ['exact'], + 'servicio': ['exact'], + 'tipo_procesamiento': ['exact'], + } + search_fields = ['pedimento__pedimento_app', 'pedimento__pedimento'] + ordering_fields = ['created_at', 'updated_at'] + ordering = ['-created_at'] + + def get_queryset(self): + return self.get_queryset_filtrado_por_organizacion() + + def perform_create(self, serializer): + """ + Asigna siempre la organización al crear un procesamiento de pedimento. + - Para superusuarios: requiere que la organización venga explícitamente en los datos validados. + - Para usuarios normales: asigna la organización del usuario autenticado. + """ + user = self.request.user + if not user.is_authenticated: + raise ValueError("Usuario no autenticado") + + # Si es superusuario, debe venir la organización en los datos validados + if user.is_superuser: + organizacion = serializer.validated_data.get('organizacion', None) + if not organizacion: + raise ValueError("El superusuario debe especificar una organización al crear el procesamiento de pedimento.") + serializer.save() + return + + # Para usuarios normales, asignar siempre la organización del usuario + if not hasattr(user, 'organizacion') or not user.organizacion: + raise ValueError("Usuario sin organización") + serializer.save(organizacion=user.organizacion) + + def perform_update(self, serializer): + """ + Permite actualizar un procesamiento de pedimento, pero solo si el usuario es superusuario o pertenece a la misma organización. + """ + if not self.request.user.is_authenticated: + raise ValueError("Usuario no autenticado") + + if self.request.user.is_superuser: + serializer.save() + return + + if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + # Para usuarios normales, usar siempre su organización + if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: + raise ValueError("Usuario sin organización") + serializer.save(organizacion=self.request.user.organizacion) + return + + raise ValueError("Usuario no autenticado o sin permisos para actualizar ProcesamientoPedimento") + + my_tags = ['Procesamientos_Pedimentos'] + +class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): + """ + ViewSet for EDocument model. + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + serializer_class = EDocumentSerializer + pagination_class = CustomPagination + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ['pedimento', 'numero_edocument', 'organizacion'] + search_fields = ['numero_edocument', 'descripcion', 'organizacion'] + ordering_fields = ['created_at', 'updated_at', 'numero_edocument'] + ordering = ['-created_at'] + model = EDocument + my_tags = ['EDocuments'] + + def get_queryset(self): + return self.get_queryset_filtrado_por_organizacion() + + def perform_create(self, serializer): + """ + Asigna automáticamente la organización del usuario autenticado al crear un EDocument. + Para superusuarios, permite especificar una organización diferente. + """ + if not self.request.user.is_authenticated: + raise ValueError("Usuario no autenticado") + + # Si es superusuario y se especifica organizacion en los datos validados + if self.request.user.is_superuser: + # Permitir que el superusuario especifique la organización + serializer.save() + return + + if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + # Para usuarios normales, usar siempre su organización + if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: + raise ValueError("Usuario sin organización") + serializer.save(organizacion=self.request.user.organizacion) + return + + raise ValueError("Usuario no autenticado o sin permisos para crear EDocument") + + def perform_update(self, serializer): + """ + Permite actualizar un EDocument, pero solo si el usuario es superusuario o pertenece a la misma organización. + """ + if not self.request.user.is_authenticated: + raise ValueError("Usuario no autenticado") + + # Si es superusuario, permite actualizar sin restricciones + if self.request.user.is_superuser: + serializer.save() + return + + if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + # Para usuarios normales, usar siempre su organización + if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: + raise ValueError("Usuario sin organización") + serializer.save(organizacion=self.request.user.organizacion) + + raise ValueError("Usuario no autenticado o sin permisos para actualizar EDocument") + +class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin): + """ + ViewSet for Cove model. + """ + permission_classes = [IsAuthenticated & (IsSuperUser |IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] + serializer_class = CoveSerializer + pagination_class = CustomPagination + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ['pedimento', 'numero_cove', 'organizacion'] + search_fields = ['numero_cove', 'descripcion', 'organizacion'] + ordering_fields = ['created_at', 'updated_at', 'numero_cove'] + ordering = ['-created_at'] + model = Cove + my_tags = ['Coves'] + + def get_queryset(self): + return self.get_queryset_filtrado_por_organizacion() + + def perform_create(self, serializer): + """ + Asigna automáticamente la organización del usuario autenticado al crear un Cove. + Para superusuarios, permite especificar una organización diferente. + """ + if not self.request.user.is_authenticated: + raise ValueError("Usuario no autenticado") + + # Si es superusuario y se especifica organizacion en los datos validados + if self.request.user.is_superuser: + # Permitir que el superusuario especifique la organización + serializer.save() + return + + if ( + self.request.user.groups.filter(name='developer').exists() + or self.request.user.groups.filter(name='admin').exists() + or self.request.user.groups.filter(name='user').exists() + ) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + # Para usuarios normales, usar siempre su organización + if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: + raise ValueError("Usuario sin organización") + serializer.save(organizacion=self.request.user.organizacion) + return + + raise ValueError("Usuario no autenticado o sin permisos para crear Cove") + + def perform_update(self, serializer): + """ + Permite actualizar un Cove, pero solo si el usuario es superusuario o pertenece a la misma organización. + """ + if not self.request.user.is_authenticated: + raise ValueError("Usuario no autenticado") + + # Si es superusuario, permite actualizar sin restricciones + if self.request.user.is_superuser: + serializer.save() + return + + if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user .groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + # Para usuarios normales, usar siempre su organización + if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: + raise ValueError("Usuario sin organización") + serializer.save(organizacion=self.request.user.organizacion) + +class ImportadorViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): + """ + ViewSet for Importador model. + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + serializer_class = ImportadorSerializer + pagination_class = CustomPagination + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ['rfc', 'nombre', 'organizacion'] + search_fields = ['rfc', 'nombre'] + ordering_fields = ['created_at', 'updated_at', 'rfc'] + ordering = ['-created_at'] + model = Importador + + def get_queryset(self): + return self.get_queryset_filtrado_por_organizacion() + + def perform_create(self, serializer): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("Usuario no autenticado o sin organización") + serializer.save(organizacion=self.request.user.organizacion) + + def perform_update(self, serializer): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("Usuario no autenticado o sin organización") + + # Si es superusuario, permite actualizar sin restricciones + if self.request.user.is_superuser: + serializer.save() + return + + if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + # Para usuarios normales, usar siempre su organización + if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: + raise ValueError("Usuario sin organización") + serializer.save(organizacion=self.request.user.organizacion) + return + + raise ValueError("Usuario no autenticado o sin permisos para actualizar Importador") + + my_tags = ['Importadores'] \ No newline at end of file diff --git a/api/datastage/__init__.py b/api/datastage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/datastage/admin.py b/api/datastage/admin.py new file mode 100644 index 0000000..49c3364 --- /dev/null +++ b/api/datastage/admin.py @@ -0,0 +1,44 @@ +from django.contrib import admin + +from .models import ( + DataStage, Registro500, + Registro501, Registro502, + Registro503, Registro504, + Registro505, Registro506, + Registro507, Registro508, + Registro509, Registro510, + Registro511, Registro512, + Registro520, Registro551, + Registro552, Registro553, + Registro554, Registro555, + Registro556, Registro557, + Registro558, RegistroSel, + Registro701, Registro702 +) +# Register your models here. +admin.site.register(DataStage) +admin.site.register(Registro500) +admin.site.register(Registro501) +admin.site.register(Registro502) +admin.site.register(Registro503) +admin.site.register(Registro504) +admin.site.register(Registro505) +admin.site.register(Registro506) +admin.site.register(Registro507) +admin.site.register(Registro508) +admin.site.register(Registro509) +admin.site.register(Registro510) +admin.site.register(Registro511) +admin.site.register(Registro512) +admin.site.register(Registro520) +admin.site.register(Registro551) +admin.site.register(Registro552) +admin.site.register(Registro553) +admin.site.register(Registro554) +admin.site.register(Registro555) +admin.site.register(Registro556) +admin.site.register(Registro557) +admin.site.register(Registro558) +admin.site.register(Registro701) +admin.site.register(Registro702) +admin.site.register(RegistroSel) diff --git a/api/datastage/apps.py b/api/datastage/apps.py new file mode 100644 index 0000000..798641c --- /dev/null +++ b/api/datastage/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class DatastageConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.datastage' + diff --git a/api/datastage/migrations/0001_initial.py b/api/datastage/migrations/0001_initial.py new file mode 100644 index 0000000..d180a72 --- /dev/null +++ b/api/datastage/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organization', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DataStage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre', models.CharField(max_length=100, unique=True)), + ('almacenamiento', models.PositiveIntegerField(default=0)), + ('archivo', models.FileField(upload_to='datastages/')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('organizacion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='datastages', to='organization.organizacion')), + ], + options={ + 'verbose_name': 'DataStage', + 'verbose_name_plural': 'DataStages', + 'db_table': 'datastage', + }, + ), + ] diff --git a/api/datastage/migrations/0002_remove_datastage_almacenamiento_and_more.py b/api/datastage/migrations/0002_remove_datastage_almacenamiento_and_more.py new file mode 100644 index 0000000..b45b98d --- /dev/null +++ b/api/datastage/migrations/0002_remove_datastage_almacenamiento_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.3 on 2025-08-14 15:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='datastage', + name='almacenamiento', + ), + migrations.RemoveField( + model_name='datastage', + name='nombre', + ), + migrations.AddField( + model_name='datastage', + name='contribuyente', + field=models.CharField(default='', max_length=100), + preserve_default=False, + ), + migrations.AddField( + model_name='datastage', + name='procesado', + field=models.BooleanField(default=False), + ), + ] diff --git a/api/datastage/migrations/0003_alter_datastage_organizacion.py b/api/datastage/migrations/0003_alter_datastage_organizacion.py new file mode 100644 index 0000000..923b533 --- /dev/null +++ b/api/datastage/migrations/0003_alter_datastage_organizacion.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.3 on 2025-08-14 15:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0002_remove_datastage_almacenamiento_and_more'), + ('organization', '0002_remove_organizacion_membretado_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='datastage', + name='organizacion', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='datastages', to='organization.organizacion'), + ), + ] diff --git a/api/datastage/migrations/0004_registro500_registro501_registro502_registro503_and_more.py b/api/datastage/migrations/0004_registro500_registro501_registro502_registro503_and_more.py new file mode 100644 index 0000000..ae920bd --- /dev/null +++ b/api/datastage/migrations/0004_registro500_registro501_registro502_registro503_and_more.py @@ -0,0 +1,588 @@ +# Generated by Django 5.2.3 on 2025-08-14 19:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0003_alter_datastage_organizacion'), + ('organization', '0002_remove_organizacion_membretado_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Registro500', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=4, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=3, null=True)), + ('consecutivo_remesa', models.CharField(blank=True, max_length=50, null=True)), + ('numero_seleccion', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_inicio_reconocimiento', models.DateField(blank=True, null=True)), + ('hora_inicio_reconocimiento', models.TimeField(blank=True, null=True)), + ('fecha_fin_reconocimiento', models.DateField(blank=True, null=True)), + ('hora_fin_reconocimiento', models.TimeField(blank=True, null=True)), + ('fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('clave_documento', models.CharField(blank=True, max_length=50, null=True)), + ('tipo_operacion', models.CharField(blank=True, max_length=50, null=True)), + ('grado_incidencia', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_seleccion', models.DateField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro500s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro500s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro500', + }, + ), + migrations.CreateModel( + name='Registro501', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=4, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=3, null=True)), + ('tipo_operacion', models.CharField(blank=True, max_length=1, null=True)), + ('clave_documento', models.CharField(blank=True, max_length=2, null=True)), + ('seccion_aduanera_entrada', models.CharField(blank=True, max_length=3, null=True)), + ('curp_contribuyente', models.CharField(blank=True, max_length=18, null=True)), + ('rfc', models.CharField(blank=True, max_length=13, null=True)), + ('curp_agente_a', models.CharField(blank=True, max_length=18, null=True)), + ('tipo_cambio', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('total_fletes', models.CharField(blank=True, max_length=12, null=True)), + ('total_seguros', models.CharField(blank=True, max_length=12, null=True)), + ('total_embalajes', models.CharField(blank=True, max_length=12, null=True)), + ('total_incrementables', models.CharField(blank=True, max_length=12, null=True)), + ('total_deducibles', models.CharField(blank=True, max_length=12, null=True)), + ('peso_bruto_mercancia', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('medio_transporte_salida', models.CharField(blank=True, max_length=2, null=True)), + ('medio_transporte_arribo', models.CharField(blank=True, max_length=2, null=True)), + ('medio_transporte_entrada_salida', models.CharField(blank=True, max_length=2, null=True)), + ('destino_mercancia', models.CharField(blank=True, max_length=2, null=True)), + ('nombre_contribuyente', models.CharField(blank=True, max_length=120, null=True)), + ('calle_contribuyente', models.CharField(blank=True, max_length=80, null=True)), + ('num_interior_contribuyente', models.CharField(blank=True, max_length=10, null=True)), + ('num_exterior_contribuyente', models.CharField(blank=True, max_length=10, null=True)), + ('cp_contribuyente', models.CharField(blank=True, max_length=10, null=True)), + ('municipio_contribuyente', models.CharField(blank=True, max_length=80, null=True)), + ('entidad_fed_contribuyente', models.CharField(blank=True, max_length=3, null=True)), + ('pais_contribuyente', models.CharField(blank=True, max_length=3, null=True)), + ('tipo_pedimento', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_recepcion_pedimento', models.DateTimeField(blank=True, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro501s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro501s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro501', + }, + ), + migrations.CreateModel( + name='Registro502', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('rfc_transportista', models.CharField(blank=True, max_length=13, null=True)), + ('curp_transportista', models.CharField(blank=True, max_length=18, null=True)), + ('nombre_transportista', models.CharField(blank=True, max_length=120, null=True)), + ('pais_transporte', models.CharField(blank=True, max_length=3, null=True)), + ('identificador_transporte', models.CharField(blank=True, max_length=17, null=True)), + ('fecha_pago_real', models.DateField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro502s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro502s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro502', + }, + ), + migrations.CreateModel( + name='Registro503', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('numero_guia', models.CharField(blank=True, max_length=20, null=True)), + ('tipo_guia', models.CharField(blank=True, max_length=1, null=True)), + ('fecha_pago_real', models.DateField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro503s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro503s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro503', + }, + ), + migrations.CreateModel( + name='Registro504', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('num_contenedor', models.CharField(blank=True, max_length=12, null=True)), + ('tipo_contenedor', models.CharField(blank=True, max_length=2, null=True)), + ('fecha_pago_real', models.DateField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro504s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro504s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro504', + }, + ), + migrations.CreateModel( + name='Registro505', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_facturacion', models.DateField(blank=True, null=True)), + ('numero_factura', models.CharField(blank=True, max_length=40, null=True)), + ('termino_facturacion', models.CharField(blank=True, max_length=3, null=True)), + ('moneda_facturacion', models.CharField(blank=True, max_length=3, null=True)), + ('valor_dolares', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('valor_moneda_extranjera', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('pais_facturacion', models.CharField(blank=True, max_length=3, null=True)), + ('entidad_fed_facturacion', models.CharField(blank=True, max_length=3, null=True)), + ('indent_fiscal_proveedor', models.CharField(blank=True, max_length=30, null=True)), + ('proveedor_mercancia', models.CharField(blank=True, max_length=120, null=True)), + ('calle_proveedor', models.CharField(blank=True, max_length=80, null=True)), + ('num_interior_proveedor', models.CharField(blank=True, max_length=10, null=True)), + ('num_exterior_proveedor', models.CharField(blank=True, max_length=10, null=True)), + ('cp_proveedor', models.CharField(blank=True, max_length=10, null=True)), + ('municipio_proveedor', models.CharField(blank=True, max_length=80, null=True)), + ('fecha_pago_real', models.CharField(blank=True, max_length=50, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro505s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro505s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro505', + }, + ), + migrations.CreateModel( + name='Registro506', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('tipo_fecha', models.CharField(blank=True, max_length=2, null=True)), + ('fecha_operacion', models.DateField(blank=True, null=True)), + ('fecha_validacion_pago_r', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro506s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro506s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro506', + }, + ), + migrations.CreateModel( + name='Registro507', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('clave_caso', models.CharField(blank=True, max_length=50, null=True)), + ('identificador_caso', models.CharField(blank=True, max_length=50, null=True)), + ('tipo_pedimento', models.CharField(blank=True, max_length=50, null=True)), + ('complemento_caso', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_validacion_pago_r', models.CharField(blank=True, max_length=50, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro507s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro507s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro507', + }, + ), + migrations.CreateModel( + name='Registro508', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('institucion_emisora', models.CharField(blank=True, max_length=2, null=True)), + ('numero_cuenta', models.CharField(blank=True, max_length=50, null=True)), + ('folio_constancia', models.CharField(blank=True, max_length=17, null=True)), + ('fecha_constancia', models.DateField(blank=True, null=True)), + ('tipo_cuenta', models.CharField(blank=True, max_length=2, null=True)), + ('clave_garantia', models.CharField(blank=True, max_length=50, null=True)), + ('valor_unitario_titulo', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('total_garantia', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('cantidad_unidades', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('titulos_asignados', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('fecha_pago_real', models.DateField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro508s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro508s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro508', + }, + ), + migrations.CreateModel( + name='Registro509', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=4, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=3, null=True)), + ('clave_contribucion', models.CharField(blank=True, max_length=2, null=True)), + ('tasa_contribucion', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('tipo_tasa', models.CharField(blank=True, max_length=2, null=True)), + ('tipo_pedimento', models.IntegerField(blank=True, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro509s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro509s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro509', + }, + ), + migrations.CreateModel( + name='Registro510', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('clave_contribucion', models.CharField(blank=True, max_length=2, null=True)), + ('tasa_contribucion', models.CharField(blank=True, max_length=50, null=True)), + ('tipo_tasa', models.CharField(blank=True, max_length=50, null=True)), + ('tipo_pedimento', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_pago_real', models.CharField(blank=True, max_length=50, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('forma_pago', models.CharField(blank=True, max_length=3, null=True)), + ('importe_pago', models.CharField(blank=True, max_length=12, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro510s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro510s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro510', + }, + ), + migrations.CreateModel( + name='Registro511', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('secuencia_observacion', models.CharField(blank=True, max_length=3, null=True)), + ('observaciones', models.CharField(blank=True, max_length=120, null=True)), + ('tipo_pedimento', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_validacion_pago_r', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro511s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro511s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro511', + }, + ), + migrations.CreateModel( + name='Registro512', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=3, null=True)), + ('patente_aduanal_orig', models.CharField(blank=True, max_length=4, null=True)), + ('pedimento_original', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera_desp_orig', models.CharField(blank=True, max_length=50, null=True)), + ('documento_original', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_operacion_orig', models.DateField(blank=True, null=True)), + ('fraccion_original', models.CharField(blank=True, max_length=8, null=True)), + ('unidad_medida', models.CharField(blank=True, max_length=2, null=True)), + ('mercancia_descargada', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('tipo_pedimento', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro512s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro512s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro512', + }, + ), + migrations.CreateModel( + name='Registro520', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('indent_fiscal_destinatario', models.CharField(blank=True, max_length=17, null=True)), + ('nombre_destinatario_mercancia', models.CharField(blank=True, max_length=120, null=True)), + ('calle_destinatario', models.CharField(blank=True, max_length=80, null=True)), + ('num_interior_destinatario', models.CharField(blank=True, max_length=10, null=True)), + ('num_exterior_destinatario', models.CharField(blank=True, max_length=10, null=True)), + ('cp_destinatario', models.CharField(blank=True, max_length=10, null=True)), + ('municipio_destinatario', models.CharField(blank=True, max_length=80, null=True)), + ('pais_destinatario', models.CharField(blank=True, max_length=3, null=True)), + ('fecha_pago_real', models.CharField(blank=True, max_length=50, null=True)), + ('created_at', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro520s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro520s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro520', + }, + ), + migrations.CreateModel( + name='Registro551', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('subdivision_fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('descripcion_mercancia', models.CharField(blank=True, max_length=250, null=True)), + ('precio_unitario', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('valor_aduana', models.CharField(blank=True, max_length=12, null=True)), + ('valor_comercial', models.CharField(blank=True, max_length=12, null=True)), + ('valor_dolares', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('cantidad_um_comercial', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('unidad_medida_comercial', models.CharField(blank=True, max_length=2, null=True)), + ('cantidad_um_tarifa', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('unidad_medida_tarifa', models.CharField(blank=True, max_length=2, null=True)), + ('valor_agregado', models.CharField(blank=True, max_length=12, null=True)), + ('clave_vinculacion', models.CharField(blank=True, max_length=1, null=True)), + ('metodo_valorizacion', models.CharField(blank=True, max_length=2, null=True)), + ('codigo_mercancia_producto', models.CharField(blank=True, max_length=20, null=True)), + ('marca_mercancia_producto', models.CharField(blank=True, max_length=80, null=True)), + ('modelo_mercancia_producto', models.CharField(blank=True, max_length=80, null=True)), + ('pais_origen_destino', models.CharField(blank=True, max_length=3, null=True)), + ('pais_comprador_vendedor', models.CharField(blank=True, max_length=3, null=True)), + ('entidad_fed_origen', models.CharField(blank=True, max_length=3, null=True)), + ('entidad_fed_comprador', models.CharField(blank=True, max_length=3, null=True)), + ('entidad_fed_vendedor', models.CharField(blank=True, max_length=3, null=True)), + ('tipo_operacion', models.CharField(blank=True, max_length=50, null=True)), + ('clave_documento', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('entidad_fed_destino', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro551s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro551s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro551', + }, + ), + migrations.CreateModel( + name='Registro552', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('vin_numero_serie', models.CharField(blank=True, max_length=25, null=True)), + ('kilometraje_vehiculo', models.CharField(blank=True, max_length=6, null=True)), + ('fecha_pago_real', models.DateField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro552s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro552s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro552', + }, + ), + migrations.CreateModel( + name='Registro553', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('clave_permiso', models.CharField(blank=True, max_length=3, null=True)), + ('firma_descargo', models.CharField(blank=True, max_length=40, null=True)), + ('numero_permiso', models.CharField(blank=True, max_length=30, null=True)), + ('valor_comercial_dolares', models.CharField(blank=True, max_length=50, null=True)), + ('cantidad_mum_tarifa', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_pago_real', models.CharField(blank=True, max_length=50, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro553s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro553s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro553', + }, + ), + migrations.CreateModel( + name='Registro554', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('clave_caso', models.CharField(blank=True, max_length=50, null=True)), + ('identificador_caso', models.CharField(blank=True, max_length=50, null=True)), + ('complemento_caso', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro554s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro554s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro554', + }, + ), + migrations.CreateModel( + name='Registro555', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('institucion_emisora', models.CharField(blank=True, max_length=2, null=True)), + ('numero_cuenta', models.CharField(blank=True, max_length=17, null=True)), + ('folio_constancia', models.CharField(blank=True, max_length=17, null=True)), + ('fecha_constancia', models.DateField(blank=True, null=True)), + ('clave_garantia', models.CharField(blank=True, max_length=50, null=True)), + ('valor_unitario_titulo', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('total_garantia', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('cantidad_unidades_medida', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('titulos_asignados', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('fecha_pago_real', models.DateField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro555s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro555s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro555', + }, + ), + migrations.CreateModel( + name='Registro556', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('clave_contribucion', models.CharField(blank=True, max_length=2, null=True)), + ('tasa_contribucion', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ('tipo_tasa', models.CharField(blank=True, max_length=2, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro556s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro556s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro556', + }, + ), + migrations.CreateModel( + name='Registro557', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('clave_contribucion', models.CharField(blank=True, max_length=2, null=True)), + ('forma_pago', models.CharField(blank=True, max_length=3, null=True)), + ('importe_pago', models.CharField(blank=True, max_length=12, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro557s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro557s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro557', + }, + ), + migrations.CreateModel( + name='Registro558', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('fraccion', models.CharField(blank=True, max_length=8, null=True)), + ('secuencia_fraccion', models.CharField(blank=True, max_length=50, null=True)), + ('secuencia_observacion', models.CharField(blank=True, max_length=3, null=True)), + ('observaciones', models.CharField(blank=True, max_length=120, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro558s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro558s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro558', + }, + ), + migrations.CreateModel( + name='RegistroSel', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=50, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('consecutivo_remesa', models.CharField(blank=True, max_length=50, null=True)), + ('numero_seleccion', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_seleccion', models.DateField(blank=True, null=True)), + ('hora_seleccion', models.TimeField(blank=True, null=True)), + ('semaforo_fiscal', models.CharField(blank=True, max_length=1, null=True)), + ('clave_documento', models.CharField(blank=True, max_length=3, null=True)), + ('tipo_operacion', models.CharField(blank=True, max_length=1, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro_sel', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro_sel', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro_sel', + }, + ), + ] diff --git a/api/datastage/migrations/0005_registro701_registro702.py b/api/datastage/migrations/0005_registro701_registro702.py new file mode 100644 index 0000000..7f651bc --- /dev/null +++ b/api/datastage/migrations/0005_registro701_registro702.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.3 on 2025-08-14 19:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0004_registro500_registro501_registro502_registro503_and_more'), + ('organization', '0002_remove_organizacion_membretado_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Registro701', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=4, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=3, null=True)), + ('clave_documento', models.CharField(blank=True, max_length=2, null=True)), + ('fecha_pago', models.DateField(blank=True, null=True)), + ('pedimento_anterior', models.CharField(blank=True, max_length=50, null=True)), + ('patente_anterior', models.CharField(blank=True, max_length=50, null=True)), + ('seccion_aduanera_anterior', models.CharField(blank=True, max_length=50, null=True)), + ('documento_anterior', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_operacion_anterior', models.DateField(blank=True, null=True)), + ('pedimento_original', models.CharField(blank=True, max_length=50, null=True)), + ('patente_aduanal_orig', models.CharField(blank=True, max_length=50, null=True)), + ('seccion_aduanera_desp_orig', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro701s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro701s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro701', + }, + ), + migrations.CreateModel( + name='Registro702', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('patente', models.CharField(blank=True, max_length=50, null=True)), + ('pedimento', models.CharField(blank=True, max_length=7, null=True)), + ('seccion_aduanera', models.CharField(blank=True, max_length=50, null=True)), + ('clave_contribucion', models.CharField(blank=True, max_length=2, null=True)), + ('forma_pago', models.CharField(blank=True, max_length=50, null=True)), + ('importe_pago', models.CharField(blank=True, max_length=12, null=True)), + ('tipo_pedimento', models.CharField(blank=True, max_length=50, null=True)), + ('fecha_pago_real', models.DateTimeField(blank=True, null=True)), + ('created_by', models.IntegerField(blank=True, null=True)), + ('consulta', models.CharField(blank=True, max_length=50, null=True)), + ('datastage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro702s', to='datastage.datastage')), + ('organizacion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='registro702s', to='organization.organizacion')), + ], + options={ + 'db_table': 'registro702', + }, + ), + ] diff --git a/api/datastage/migrations/0006_rename_municipio_destinatario_registro520_municpio_destinatario_and_more.py b/api/datastage/migrations/0006_rename_municipio_destinatario_registro520_municpio_destinatario_and_more.py new file mode 100644 index 0000000..e5a2ba0 --- /dev/null +++ b/api/datastage/migrations/0006_rename_municipio_destinatario_registro520_municpio_destinatario_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.3 on 2025-08-14 21:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0005_registro701_registro702'), + ] + + operations = [ + migrations.RenameField( + model_name='registro520', + old_name='municipio_destinatario', + new_name='municpio_destinatario', + ), + migrations.RemoveField( + model_name='registro520', + name='created_by', + ), + ] diff --git a/api/datastage/migrations/0007_alter_datastage_archivo_and_more.py b/api/datastage/migrations/0007_alter_datastage_archivo_and_more.py new file mode 100644 index 0000000..2828e05 --- /dev/null +++ b/api/datastage/migrations/0007_alter_datastage_archivo_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.3 on 2025-08-14 21:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0006_rename_municipio_destinatario_registro520_municpio_destinatario_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='datastage', + name='archivo', + field=models.FileField(blank=True, null=True, upload_to='datastages/'), + ), + migrations.AlterField( + model_name='datastage', + name='contribuyente', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/api/datastage/migrations/0008_alter_datastage_archivo_and_more.py b/api/datastage/migrations/0008_alter_datastage_archivo_and_more.py new file mode 100644 index 0000000..217876a --- /dev/null +++ b/api/datastage/migrations/0008_alter_datastage_archivo_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-08-14 21:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0007_alter_datastage_archivo_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='datastage', + name='archivo', + field=models.FileField(default='', upload_to='datastages/'), + preserve_default=False, + ), + migrations.AlterField( + model_name='datastage', + name='contribuyente', + field=models.CharField(default='', max_length=100), + preserve_default=False, + ), + ] diff --git a/api/datastage/migrations/0009_alter_registro501_tipo_operacion_and_more.py b/api/datastage/migrations/0009_alter_registro501_tipo_operacion_and_more.py new file mode 100644 index 0000000..213de74 --- /dev/null +++ b/api/datastage/migrations/0009_alter_registro501_tipo_operacion_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.3 on 2025-08-14 21:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0008_alter_datastage_archivo_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='registro501', + name='tipo_operacion', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='registrosel', + name='tipo_operacion', + field=models.CharField(blank=True, max_length=50, null=True), + ), + ] diff --git a/api/datastage/migrations/0010_alter_registro501_clave_documento_and_more.py b/api/datastage/migrations/0010_alter_registro501_clave_documento_and_more.py new file mode 100644 index 0000000..071267e --- /dev/null +++ b/api/datastage/migrations/0010_alter_registro501_clave_documento_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.3 on 2025-08-14 21:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0009_alter_registro501_tipo_operacion_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='registro501', + name='clave_documento', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='registro502', + name='pais_transporte', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='registro503', + name='tipo_guia', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='registro701', + name='clave_documento', + field=models.CharField(blank=True, max_length=50, null=True), + ), + ] diff --git a/api/datastage/migrations/0011_alter_registro502_fecha_pago_real_and_more.py b/api/datastage/migrations/0011_alter_registro502_fecha_pago_real_and_more.py new file mode 100644 index 0000000..e11768d --- /dev/null +++ b/api/datastage/migrations/0011_alter_registro502_fecha_pago_real_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.3 on 2025-08-14 21:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datastage', '0010_alter_registro501_clave_documento_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='registro502', + name='fecha_pago_real', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='registro503', + name='fecha_pago_real', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='registro504', + name='fecha_pago_real', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/api/datastage/migrations/__init__.py b/api/datastage/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/datastage/models.py b/api/datastage/models.py new file mode 100644 index 0000000..0693d4a --- /dev/null +++ b/api/datastage/models.py @@ -0,0 +1,571 @@ +from django.db import models + +# Create your models here. +class DataStage(models.Model): + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='datastages', null=True, blank=True) + archivo = models.FileField(upload_to='datastages/', blank=False, null=False) + contribuyente = models.CharField(max_length=100, blank=False, null=False) + procesado = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + class Meta: + verbose_name = "DataStage" + verbose_name_plural = "DataStages" + db_table = 'datastage' + + def __str__(self): + return organizacion.nombre if self.organizacion else "DataStage sin organizacion" + +class Registro500(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=4, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=3, null=True, blank=True) + consecutivo_remesa = models.CharField(max_length=50, null=True, blank=True) + numero_seleccion = models.CharField(max_length=50, null=True, blank=True) + fecha_inicio_reconocimiento = models.DateField(null=True, blank=True) + hora_inicio_reconocimiento = models.TimeField(null=True, blank=True) + fecha_fin_reconocimiento = models.DateField(null=True, blank=True) + hora_fin_reconocimiento = models.TimeField(null=True, blank=True) + fraccion = models.CharField(max_length=50, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + clave_documento = models.CharField(max_length=50, null=True, blank=True) + tipo_operacion = models.CharField(max_length=50, null=True, blank=True) + grado_incidencia = models.CharField(max_length=50, null=True, blank=True) + fecha_seleccion = models.DateField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro500s', null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro500s', null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.patente} - {self.pedimento} - {self.seccion_aduanera}" + + class Meta: + db_table = 'registro500' + +class Registro501(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=4, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=3, null=True, blank=True) + tipo_operacion = models.CharField(max_length=50, null=True, blank=True) + clave_documento = models.CharField(max_length=50, null=True, blank=True) + seccion_aduanera_entrada = models.CharField(max_length=3, null=True, blank=True) + curp_contribuyente = models.CharField(max_length=18, null=True, blank=True) + rfc = models.CharField(max_length=13, null=True, blank=True) + curp_agente_a = models.CharField(max_length=18, null=True, blank=True) + tipo_cambio = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + total_fletes = models.CharField(max_length=12, null=True, blank=True) + total_seguros = models.CharField(max_length=12, null=True, blank=True) + total_embalajes = models.CharField(max_length=12, null=True, blank=True) + total_incrementables = models.CharField(max_length=12, null=True, blank=True) + total_deducibles = models.CharField(max_length=12, null=True, blank=True) + peso_bruto_mercancia = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + medio_transporte_salida = models.CharField(max_length=2, null=True, blank=True) + medio_transporte_arribo = models.CharField(max_length=2, null=True, blank=True) + medio_transporte_entrada_salida = models.CharField(max_length=2, null=True, blank=True) + destino_mercancia = models.CharField(max_length=2, null=True, blank=True) + nombre_contribuyente = models.CharField(max_length=120, null=True, blank=True) + calle_contribuyente = models.CharField(max_length=80, null=True, blank=True) + num_interior_contribuyente = models.CharField(max_length=10, null=True, blank=True) + num_exterior_contribuyente = models.CharField(max_length=10, null=True, blank=True) + cp_contribuyente = models.CharField(max_length=10, null=True, blank=True) + municipio_contribuyente = models.CharField(max_length=80, null=True, blank=True) + entidad_fed_contribuyente = models.CharField(max_length=3, null=True, blank=True) + pais_contribuyente = models.CharField(max_length=3, null=True, blank=True) + tipo_pedimento = models.CharField(max_length=50, null=True, blank=True) + fecha_recepcion_pedimento = models.DateTimeField(null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True) + + class Meta: + db_table = 'registro501' + +class Registro502(models.Model): + id = models.AutoField(primary_key=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + rfc_transportista = models.CharField(max_length=13, null=True, blank=True) + curp_transportista = models.CharField(max_length=18, null=True, blank=True) + nombre_transportista = models.CharField(max_length=120, null=True, blank=True) + pais_transporte = models.CharField(max_length=50, null=True, blank=True) + identificador_transporte = models.CharField(max_length=17, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro502s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro502s', null=True, blank=True) + patente = models.CharField(max_length=50, null=True, blank=True) + + class Meta: + db_table = 'registro502' + +class Registro503(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + numero_guia = models.CharField(max_length=20, null=True, blank=True) + tipo_guia = models.CharField(max_length=50, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro503s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro503s', null=True, blank=True) + + class Meta: + db_table = 'registro503' + +class Registro504(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + num_contenedor = models.CharField(max_length=12, null=True, blank=True) + tipo_contenedor = models.CharField(max_length=2, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro504s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro504s', null=True, blank=True) + + class Meta: + db_table = 'registro504' + +class Registro505(models.Model): + id = models.AutoField(primary_key=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + fecha_facturacion = models.DateField(null=True, blank=True) + numero_factura = models.CharField(max_length=40, null=True, blank=True) + termino_facturacion = models.CharField(max_length=3, null=True, blank=True) + moneda_facturacion = models.CharField(max_length=3, null=True, blank=True) + valor_dolares = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + valor_moneda_extranjera = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + pais_facturacion = models.CharField(max_length=3, null=True, blank=True) + entidad_fed_facturacion = models.CharField(max_length=3, null=True, blank=True) + indent_fiscal_proveedor = models.CharField(max_length=30, null=True, blank=True) + proveedor_mercancia = models.CharField(max_length=120, null=True, blank=True) + calle_proveedor = models.CharField(max_length=80, null=True, blank=True) + num_interior_proveedor = models.CharField(max_length=10, null=True, blank=True) + num_exterior_proveedor = models.CharField(max_length=10, null=True, blank=True) + cp_proveedor = models.CharField(max_length=10, null=True, blank=True) + municipio_proveedor = models.CharField(max_length=80, null=True, blank=True) + fecha_pago_real = models.CharField(max_length=50, null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro505s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro505s', null=True, blank=True) + patente = models.CharField(max_length=50, null=True, blank=True) + + class Meta: + db_table = 'registro505' + +class Registro506(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + tipo_fecha = models.CharField(max_length=2, null=True, blank=True) + fecha_operacion = models.DateField(null=True, blank=True) + fecha_validacion_pago_r = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro506s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro506s', null=True, blank=True) + + class Meta: + db_table = 'registro506' + +class Registro507(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + clave_caso = models.CharField(max_length=50, null=True, blank=True) + identificador_caso = models.CharField(max_length=50, null=True, blank=True) + tipo_pedimento = models.CharField(max_length=50, null=True, blank=True) + complemento_caso = models.CharField(max_length=50, null=True, blank=True) + fecha_validacion_pago_r = models.CharField(max_length=50, null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro507s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro507s', null=True, blank=True) + + class Meta: + db_table = 'registro507' + +class Registro508(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + institucion_emisora = models.CharField(max_length=2, null=True, blank=True) + numero_cuenta = models.CharField(max_length=50, null=True, blank=True) + folio_constancia = models.CharField(max_length=17, null=True, blank=True) + fecha_constancia = models.DateField(null=True, blank=True) + tipo_cuenta = models.CharField(max_length=2, null=True, blank=True) + clave_garantia = models.CharField(max_length=50, null=True, blank=True) + valor_unitario_titulo = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + total_garantia = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + cantidad_unidades = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + titulos_asignados = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + fecha_pago_real = models.DateField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro508s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro508s', null=True, blank=True) + + class Meta: + db_table = 'registro508' + +class Registro509(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=4, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=3, null=True, blank=True) + clave_contribucion = models.CharField(max_length=2, null=True, blank=True) + tasa_contribucion = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + tipo_tasa = models.CharField(max_length=2, null=True, blank=True) + tipo_pedimento = models.IntegerField(null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro509s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro509s', null=True, blank=True) + + class Meta: + db_table = 'registro509' + +class Registro510(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + clave_contribucion = models.CharField(max_length=2, null=True, blank=True) + tasa_contribucion = models.CharField(max_length=50, null=True, blank=True) + tipo_tasa = models.CharField(max_length=50, null=True, blank=True) + tipo_pedimento = models.CharField(max_length=50, null=True, blank=True) + fecha_pago_real = models.CharField(max_length=50, null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro510s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro510s', null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + forma_pago = models.CharField(max_length=3, null=True, blank=True) + importe_pago = models.CharField(max_length=12, null=True, blank=True) + + class Meta: + db_table = 'registro510' + +class Registro511(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + secuencia_observacion = models.CharField(max_length=3, null=True, blank=True) + observaciones = models.CharField(max_length=120, null=True, blank=True) + tipo_pedimento = models.CharField(max_length=50, null=True, blank=True) + fecha_validacion_pago_r = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro511s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro511s', null=True, blank=True) + + class Meta: + db_table = 'registro511' + +class Registro512(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=3, null=True, blank=True) + patente_aduanal_orig = models.CharField(max_length=4, null=True, blank=True) + pedimento_original = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera_desp_orig = models.CharField(max_length=50, null=True, blank=True) + documento_original = models.CharField(max_length=50, null=True, blank=True) + fecha_operacion_orig = models.DateField(null=True, blank=True) + fraccion_original = models.CharField(max_length=8, null=True, blank=True) + unidad_medida = models.CharField(max_length=2, null=True, blank=True) + mercancia_descargada = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + tipo_pedimento = models.CharField(max_length=50, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro512s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro512s', null=True, blank=True) + + class Meta: + db_table = 'registro512' + +class Registro520(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + indent_fiscal_destinatario = models.CharField(max_length=17, null=True, blank=True) + nombre_destinatario_mercancia = models.CharField(max_length=120, null=True, blank=True) + calle_destinatario = models.CharField(max_length=80, null=True, blank=True) + num_interior_destinatario = models.CharField(max_length=10, null=True, blank=True) + num_exterior_destinatario = models.CharField(max_length=10, null=True, blank=True) + cp_destinatario = models.CharField(max_length=10, null=True, blank=True) + municpio_destinatario = models.CharField(max_length=80, null=True, blank=True) + pais_destinatario = models.CharField(max_length=3, null=True, blank=True) + fecha_pago_real = models.CharField(max_length=50, null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro520s', null=True, blank=True) + created_at = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro520s', null=True, blank=True) + + class Meta: + db_table = 'registro520' + +class Registro551(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + fraccion = models.CharField(max_length=8, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + subdivision_fraccion = models.CharField(max_length=8, null=True, blank=True) + descripcion_mercancia = models.CharField(max_length=250, null=True, blank=True) + precio_unitario = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + valor_aduana = models.CharField(max_length=12, null=True, blank=True) + valor_comercial = models.CharField(max_length=12, null=True, blank=True) + valor_dolares = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + cantidad_um_comercial = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + unidad_medida_comercial = models.CharField(max_length=2, null=True, blank=True) + cantidad_um_tarifa = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + unidad_medida_tarifa = models.CharField(max_length=2, null=True, blank=True) + valor_agregado = models.CharField(max_length=12, null=True, blank=True) + clave_vinculacion = models.CharField(max_length=1, null=True, blank=True) + metodo_valorizacion = models.CharField(max_length=2, null=True, blank=True) + codigo_mercancia_producto = models.CharField(max_length=20, null=True, blank=True) + marca_mercancia_producto = models.CharField(max_length=80, null=True, blank=True) + modelo_mercancia_producto = models.CharField(max_length=80, null=True, blank=True) + pais_origen_destino = models.CharField(max_length=3, null=True, blank=True) + pais_comprador_vendedor = models.CharField(max_length=3, null=True, blank=True) + entidad_fed_origen = models.CharField(max_length=3, null=True, blank=True) + entidad_fed_comprador = models.CharField(max_length=3, null=True, blank=True) + entidad_fed_vendedor = models.CharField(max_length=3, null=True, blank=True) + tipo_operacion = models.CharField(max_length=50, null=True, blank=True) + clave_documento = models.CharField(max_length=50, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro551s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro551s', null=True, blank=True) + entidad_fed_destino = models.CharField(max_length=50, null=True, blank=True) + + class Meta: + db_table = 'registro551' + +class Registro552(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + fraccion = models.CharField(max_length=8, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + vin_numero_serie = models.CharField(max_length=25, null=True, blank=True) + kilometraje_vehiculo = models.CharField(max_length=6, null=True, blank=True) + fecha_pago_real = models.DateField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro552s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro552s', null=True, blank=True) + + class Meta: + db_table = 'registro552' + +class Registro553(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + fraccion = models.CharField(max_length=8, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + clave_permiso = models.CharField(max_length=3, null=True, blank=True) + firma_descargo = models.CharField(max_length=40, null=True, blank=True) + numero_permiso = models.CharField(max_length=30, null=True, blank=True) + valor_comercial_dolares = models.CharField(max_length=50, null=True, blank=True) + cantidad_mum_tarifa = models.CharField(max_length=50, null=True, blank=True) + fecha_pago_real = models.CharField(max_length=50, null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro553s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro553s', null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + + class Meta: + db_table = 'registro553' + +class Registro554(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + fraccion = models.CharField(max_length=8, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + clave_caso = models.CharField(max_length=50, null=True, blank=True) + identificador_caso = models.CharField(max_length=50, null=True, blank=True) + complemento_caso = models.CharField(max_length=50, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro554s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro554s', null=True, blank=True) + + class Meta: + db_table = 'registro554' + +class Registro555(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + fraccion = models.CharField(max_length=8, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + institucion_emisora = models.CharField(max_length=2, null=True, blank=True) + numero_cuenta = models.CharField(max_length=17, null=True, blank=True) + folio_constancia = models.CharField(max_length=17, null=True, blank=True) + fecha_constancia = models.DateField(null=True, blank=True) + clave_garantia = models.CharField(max_length=50, null=True, blank=True) + valor_unitario_titulo = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + total_garantia = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + cantidad_unidades_medida = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + titulos_asignados = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + fecha_pago_real = models.DateField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro555s', null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro555s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + + class Meta: + db_table = 'registro555' + +class Registro556(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + clave_contribucion = models.CharField(max_length=2, null=True, blank=True) + tasa_contribucion = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + tipo_tasa = models.CharField(max_length=2, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro556s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro556s', null=True, blank=True) + fraccion = models.CharField(max_length=8, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + + class Meta: + db_table = 'registro556' + +class Registro557(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + fraccion = models.CharField(max_length=8, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + clave_contribucion = models.CharField(max_length=2, null=True, blank=True) + forma_pago = models.CharField(max_length=3, null=True, blank=True) + importe_pago = models.CharField(max_length=12, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro557s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro557s', null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + + class Meta: + db_table = 'registro557' + +class Registro558(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + fraccion = models.CharField(max_length=8, null=True, blank=True) + secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True) + secuencia_observacion = models.CharField(max_length=3, null=True, blank=True) + observaciones = models.CharField(max_length=120, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro558s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro558s', null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + + class Meta: + db_table = 'registro558' + +class RegistroSel(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=50, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + consecutivo_remesa = models.CharField(max_length=50, null=True, blank=True) + numero_seleccion = models.CharField(max_length=50, null=True, blank=True) + fecha_seleccion = models.DateField(null=True, blank=True) + hora_seleccion = models.TimeField(null=True, blank=True) + semaforo_fiscal = models.CharField(max_length=1, null=True, blank=True) + clave_documento = models.CharField(max_length=3, null=True, blank=True) + tipo_operacion = models.CharField(max_length=50, null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro_sel', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro_sel', null=True, blank=True) + + class Meta: + db_table = 'registro_sel' + +class Registro701(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=4, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=3, null=True, blank=True) + clave_documento = models.CharField(max_length=50, null=True, blank=True) + fecha_pago = models.DateField(null=True, blank=True) + pedimento_anterior = models.CharField(max_length=50, null=True, blank=True) + patente_anterior = models.CharField(max_length=50, null=True, blank=True) + seccion_aduanera_anterior = models.CharField(max_length=50, null=True, blank=True) + documento_anterior = models.CharField(max_length=50, null=True, blank=True) + fecha_operacion_anterior = models.DateField(null=True, blank=True) + pedimento_original = models.CharField(max_length=50, null=True, blank=True) + patente_aduanal_orig = models.CharField(max_length=50, null=True, blank=True) + seccion_aduanera_desp_orig = models.CharField(max_length=50, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro701s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro701s', null=True, blank=True) + + class Meta: + db_table = 'registro701' + +class Registro702(models.Model): + id = models.AutoField(primary_key=True) + patente = models.CharField(max_length=50, null=True, blank=True) + pedimento = models.CharField(max_length=7, null=True, blank=True) + seccion_aduanera = models.CharField(max_length=50, null=True, blank=True) + clave_contribucion = models.CharField(max_length=2, null=True, blank=True) + forma_pago = models.CharField(max_length=50, null=True, blank=True) + importe_pago = models.CharField(max_length=12, null=True, blank=True) + tipo_pedimento = models.CharField(max_length=50, null=True, blank=True) + fecha_pago_real = models.DateTimeField(null=True, blank=True) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro702s', null=True, blank=True) + created_by = models.IntegerField(null=True, blank=True) + consulta = models.CharField(max_length=50, null=True, blank=True) + datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro702s', null=True, blank=True) + + class Meta: + db_table = 'registro702' + + + + diff --git a/api/datastage/serializer.py b/api/datastage/serializer.py new file mode 100644 index 0000000..8988917 --- /dev/null +++ b/api/datastage/serializer.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from .models import DataStage +from api.organization.models import Organizacion + +class DataStageSerializer(serializers.ModelSerializer): + organizacion = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Organizacion.objects.all()) + + class Meta: + model = DataStage + fields = '__all__' + read_only_fields = ('id', 'created_at', 'updated_at') \ No newline at end of file diff --git a/api/datastage/tasks.py b/api/datastage/tasks.py new file mode 100644 index 0000000..64c7216 --- /dev/null +++ b/api/datastage/tasks.py @@ -0,0 +1,276 @@ +from celery import group +from celery import shared_task +import logging +from django.apps import apps +from django.utils import timezone +import os +import zipfile +import re + +@shared_task +def procesar_datastage_task(datastage_id, user_organizacion_id=None): + import traceback + try: + logger = logging.getLogger(__name__) + from api.datastage.models import DataStage + from api.organization.models import Organizacion + from api.customs.models import Pedimento, TipoOperacion, Regimen + + datastage = DataStage.objects.get(id=datastage_id) + if not datastage.archivo: + return {'detail': 'No hay archivo asociado a este DataStage.'} + file_path = datastage.archivo.path + if not os.path.exists(file_path): + return {'detail': 'El archivo no existe en el servidor.'} + if not file_path.endswith('.zip'): + return {'detail': 'El archivo no es un .zip.'} + + documentos_encontrados = [] + registros_cargados = {} + registros_por_archivo = {} + errores_por_archivo = {} + errores_pedimento = [] + user_organizacion = None + + if user_organizacion_id: + user_organizacion = Organizacion.objects.get(id=user_organizacion_id) + + def to_snake_case(name): + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) + return s2.replace('__', '_').lower() + + # Lanzar una subtarea por cada archivo ASC + subtasks = [] + with zipfile.ZipFile(file_path, 'r') as zip_ref: + for asc_name in zip_ref.namelist(): + if asc_name.endswith('.asc'): + subtasks.append(procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name)) + if subtasks: + job = group(subtasks).apply_async() + return { + 'group_id': job.id, + 'subtask_ids': [t.id for t in job.results], + 'detail': 'Procesamiento lanzado. Monitorea el estado de cada subtask_id.' + } + return {'detail': 'No se encontraron archivos .asc'} + except Exception as e: + import traceback + return {'error': str(e), 'traceback': traceback.format_exc()} + +@shared_task +def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name): + import traceback + try: + logger = logging.getLogger(__name__) + from api.datastage.models import DataStage + from api.organization.models import Organizacion + from api.customs.models import Pedimento, TipoOperacion, Regimen + from django.apps import apps + import zipfile + import re + datastage = DataStage.objects.get(id=datastage_id) + user_organizacion = None + if user_organizacion_id: + user_organizacion = Organizacion.objects.get(id=user_organizacion_id) + file_path = datastage.archivo.path + def to_snake_case(name): + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) + return s2.replace('__', '_').lower() + with zipfile.ZipFile(file_path, 'r') as zip_ref: + if asc_name not in zip_ref.namelist(): + return {'errores': [f'{asc_name} no encontrado en el zip']} + match = re.match(r'.*_(\d+)\.asc$', asc_name) + if match: + registro_key = match.group(1) + model_name = f'Registro{registro_key}' + else: + match2 = re.match(r'.*_([A-Za-z]+)\.asc$', asc_name) + if match2: + registro_key = match2.group(1).capitalize() + model_name = f'Registro{registro_key}' + else: + return {'errores': ["No se pudo determinar el modelo"]} + try: + Model = apps.get_model('datastage', model_name) + except LookupError: + return {'errores': [f"No existe el modelo para {model_name}"]} + with zip_ref.open(asc_name) as asc_file: + first = True + field_names = [] + field_names_snake = [] + objects_to_create = [] + errores_pedimento = [] + for line in asc_file: + line_decoded = None + try: + line_decoded = line.decode('utf-8').strip() + except UnicodeDecodeError: + try: + line_decoded = line.decode('latin-1').strip() + except Exception as e: + continue + except Exception as e: + continue + if not line_decoded: + continue + if first: + field_names = [f for f in line_decoded.split('|')] + field_names_snake = [to_snake_case(f) for f in field_names] + first = False + continue + values = line_decoded.split('|') + while values and values[-1] == '': + values.pop() + if len(values) == len(field_names_snake) + 1 and values[-1] == '': + values = values[:-1] + if len(values) < len(field_names_snake): + values += [None] * (len(field_names_snake) - len(values)) + if len(values) != len(field_names_snake): + continue + data = dict(zip(field_names_snake, values)) + if hasattr(Model, 'organizacion_id'): + data['organizacion_id'] = user_organizacion.id if user_organizacion else None + if hasattr(Model, 'datastage_id'): + data['datastage_id'] = datastage.id + # Limpiar campos de fecha vacíos ('') a None + for field in Model._meta.get_fields(): + if hasattr(field, 'get_internal_type') and field.get_internal_type() in ["DateField", "DateTimeField"]: + if data.get(field.name) == "": + data[field.name] = None + # Convertir fecha_pago_real a timezone-aware si existe + if 'fecha_pago_real' in data and data['fecha_pago_real']: + from django.utils import timezone + import datetime + fecha_val = data['fecha_pago_real'] + if isinstance(fecha_val, str): + try: + dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d %H:%M:%S') + except ValueError: + try: + dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d') + except Exception: + dt = None + if dt and timezone.is_naive(dt): + dt = timezone.make_aware(dt) + if dt: + data['fecha_pago_real'] = dt + elif isinstance(fecha_val, datetime.datetime) and timezone.is_naive(fecha_val): + data['fecha_pago_real'] = timezone.make_aware(fecha_val) + try: + obj = Model(**data) + objects_to_create.append(obj) + # Si es Registro501, crear Pedimento + if model_name == 'Registro501': + organizacion_instance = None + org_id = data.get('organizacion_id') + if org_id: + try: + organizacion_instance = Organizacion.objects.get(id=org_id) + except Exception as org_exc: + logger.warning(f"No se encontró la organización con id {org_id}: {org_exc}") + if not organizacion_instance: + organizacion_instance = user_organizacion + fecha_pago_raw = data.get('fecha_pago_real') + fecha_pago = None + if fecha_pago_raw: + if isinstance(fecha_pago_raw, str): + fecha_pago = fecha_pago_raw.split(' ')[0] + elif hasattr(fecha_pago_raw, 'date'): + fecha_pago = fecha_pago_raw.date() + else: + fecha_pago = fecha_pago_raw + aduana = data.get('seccion_aduanera') + patente = data.get('patente') + pedimento_num = data.get('pedimento') + pedimento_app = "" + try: + if fecha_pago and aduana and patente and pedimento_num: + if isinstance(fecha_pago, str): + year = fecha_pago[:4] + else: + year = str(fecha_pago.year) + pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}" + except Exception as ped_app_exc: + logger.warning(f"No se pudo generar pedimento_app: {ped_app_exc}") + tipo_operacion_val = data.get('tipo_operacion') + tipo_operacion = TipoOperacion.objects.filter(id=int(tipo_operacion_val)).first() if tipo_operacion_val else None + regimen = Regimen.objects.filter(claveped=data.get('clave_documento', '').strip(), tipo=tipo_operacion.id if tipo_operacion else None).first() if tipo_operacion else None + # Buscar o crear Importador para el RFC + importador_instance = None + rfc = data.get('rfc') + if rfc: + from api.customs.models import Importador + importador_instance = Importador.objects.filter(rfc=rfc).first() + if not importador_instance and organizacion_instance: + importador_instance = Importador.objects.create(rfc=rfc, organizacion=organizacion_instance) + pedimento_data = { + 'pedimento': pedimento_num, + 'patente': patente, + 'aduana': aduana, + 'regimen': regimen.regimenped if regimen else None, + 'clave_pedimento': data.get('clave_documento'), + 'pedimento_app': pedimento_app, + 'organizacion': organizacion_instance, + 'patente': patente, + 'fecha_pago': fecha_pago, + 'alerta': False, + 'contribuyente': importador_instance, + 'agente_aduanal': data.get('curp_agente_a'), + "tipo_operacion": tipo_operacion, + "numero_partidas": data.get('numero_partidas', 0), + "importe_total": data.get('importe_total', 0.0), + "saldo_disponible": data.get('saldo_disponible', 0.0), + "importe_pedimento": data.get('importe_pedimento', 0.0), + "existe_expediente": data.get('existe_expediente', False), + "remesas": data.get('remesas', False), + } + try: + Pedimento.objects.create(**pedimento_data) + except Exception as ped_exc: + pass + except Exception as e: + continue + if objects_to_create: + try: + Model.objects.bulk_create(objects_to_create, batch_size=1000) + except Exception as e: + return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()} + return { + 'archivo': asc_name, + 'insertados': len(objects_to_create) + } + except Exception as e: + import traceback + return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()} + + detalles = {} + for key in ['502', '503', '504']: + model_name = f'Registro{key}' + asc_file = None + encabezado = None + errores = [] + for asc_name in registros_por_archivo: + if asc_name.endswith(f'_{key}.asc'): + asc_file = asc_name + break + if asc_file: + try: + with zipfile.ZipFile(file_path, 'r') as zip_ref: + with zip_ref.open(asc_file) as f: + for line in f: + try: + encabezado = line.decode('utf-8').strip() + except UnicodeDecodeError: + encabezado = line.decode('latin-1').strip() + break + except Exception as e: + encabezado = f'Error leyendo encabezado: {e}' + errores = errores_por_archivo.get(asc_file, []) + detalles[model_name] = { + 'archivo': asc_file, + 'encabezado': encabezado, + 'errores': errores + } + return {'registros_cargados': registros_cargados, 'errores_pedimento': errores_pedimento} diff --git a/api/datastage/tests.py b/api/datastage/tests.py new file mode 100644 index 0000000..476bd8d --- /dev/null +++ b/api/datastage/tests.py @@ -0,0 +1,85 @@ + +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from api.organization.models import Organizacion +from .models import DataStage +from io import BytesIO +from django.core.files.uploadedfile import SimpleUploadedFile + +User = get_user_model() + +class DataStageViewSetTests(APITestCase): + def setUp(self): + self.org = Organizacion.objects.create(nombre="OrgTest", is_active=True, is_verified=True) + self.org2 = Organizacion.objects.create(nombre="OrgTest2", is_active=True, is_verified=True) + self.admin = User.objects.create_user(username="admin", password="adminpass", organizacion=self.org) + self.admin.groups.create(name="admin") + self.superuser = User.objects.create_superuser(username="superuser", password="superpass") + self.importador = User.objects.create_user(username="importador", password="importpass", organizacion=self.org2, is_importador=True, rfc="RFC123456789") + self.importador.groups.create(name="importador") + self.client = APIClient() + + def test_admin_sees_only_own_org(self): + ds1 = DataStage.objects.create(nombre="DS1", almacenamiento=10, organizacion=self.org, archivo=SimpleUploadedFile("a.txt", b"a")) + ds2 = DataStage.objects.create(nombre="DS2", almacenamiento=20, organizacion=self.org2, archivo=SimpleUploadedFile("b.txt", b"b")) + self.client.force_authenticate(user=self.admin) + url = reverse('datastage-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + nombres = [ds['nombre'] for ds in response.data] + self.assertIn("DS1", nombres) + self.assertNotIn("DS2", nombres) + + def test_superuser_sees_all(self): + ds1 = DataStage.objects.create(nombre="DS1", almacenamiento=10, organizacion=self.org, archivo=SimpleUploadedFile("a.txt", b"a")) + ds2 = DataStage.objects.create(nombre="DS2", almacenamiento=20, organizacion=self.org2, archivo=SimpleUploadedFile("b.txt", b"b")) + self.client.force_authenticate(user=self.superuser) + url = reverse('datastage-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + nombres = [ds['nombre'] for ds in response.data] + self.assertIn("DS1", nombres) + self.assertIn("DS2", nombres) + + def test_importador_cannot_create(self): + self.client.force_authenticate(user=self.importador) + url = reverse('datastage-list') + file_content = BytesIO(b"dummy data") + file = SimpleUploadedFile("test.txt", file_content.read(), content_type="text/plain") + data = { + "nombre": "DataStageImportador", + "almacenamiento": 10, + "archivo": file + } + response = self.client.post(url, data, format='multipart') + self.assertNotIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) + + def test_list_datastages(self): + url = reverse('datastage-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_datastage(self): + url = reverse('datastage-list') + file_content = BytesIO(b"dummy data") + file = SimpleUploadedFile("test.txt", file_content.read(), content_type="text/plain") + data = { + "nombre": "DataStageTest", + "almacenamiento": 10, + "archivo": file + } + response = self.client.post(url, data, format='multipart') + self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) + + def test_update_datastage(self): + # First create + file_content = BytesIO(b"dummy data") + file = SimpleUploadedFile("test.txt", file_content.read(), content_type="text/plain") + ds = DataStage.objects.create(nombre="DataStageTest", almacenamiento=10, organizacion=self.org, archivo=file) + url = reverse('datastage-detail', args=[ds.id]) + data = {"almacenamiento": 20} + response = self.client.patch(url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['almacenamiento'], 20) diff --git a/api/datastage/urls.py b/api/datastage/urls.py new file mode 100644 index 0000000..2f3b0ae --- /dev/null +++ b/api/datastage/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import DataStageViewSet + +# Create a router and register our viewset with it. +router = DefaultRouter() +router.register(r'datastages', DataStageViewSet, basename='datastage') + +# The API URLs are now determined automatically by the router. +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/api/datastage/views.py b/api/datastage/views.py new file mode 100644 index 0000000..1ec05b6 --- /dev/null +++ b/api/datastage/views.py @@ -0,0 +1,140 @@ +from rest_framework.pagination import PageNumberPagination +from api.customs.models import Pedimento, TipoOperacion, Regimen +from django.shortcuts import render +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action +from rest_framework.response import Response +from django.http import FileResponse, Http404 +import os + +from .models import DataStage +from .serializer import DataStageSerializer + +from api.logger.mixins import LoggingMixin +from mixins.filtrado_organizacion import OrganizacionFiltradaMixin +from core.permissions import ( + IsSameOrganization, + IsSameOrganizationDeveloper, + IsSameOrganizationAndAdmin, + IsSuperUser +) +# Create your views here. +class DataStagePagination(PageNumberPagination): + page_size = 20 # Valor por defecto + page_size_query_param = 'page_size' + max_page_size = 1000 + +class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): + + + """ + ViewSet for managing DataStage instances. + Provides CRUD operations for DataStage. + """ + + + serializer_class = DataStageSerializer + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + model = DataStage + my_tags = ['DataStage'] + pagination_class = DataStagePagination + + def get_queryset(self): + if self.request.user.is_superuser: + return DataStage.objects.all().order_by('-created_at') + + if self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Agente Aduanal').exists(): + return DataStage.objects.filter(organizacion=self.request.user.organizacion).order_by('-created_at') + + return self.get_queryset_filtrado_por_organizacion().order_by('-created_at') + + def perform_create(self, serializer): + """ + Permite que la organización sea opcional en el request, pero si no se envía, se asigna la del usuario autenticado. + """ + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("Usuario no autenticado o sin organización") + + data = serializer.validated_data + organizacion = data.get('organizacion') + + if self.request.user.is_superuser: + # Permitir que el superusuario cree sin organización o la especifique + serializer.save() + return + + if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + if not organizacion: + serializer.save(organizacion=self.request.user.organizacion) + else: + serializer.save() + return + + raise ValueError("No cuentas con los permisos necesarios para crear un DataStage") + + def perform_update(self, serializer): + """ + Override to ensure organization is set on update. + """ + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("Usuario no autenticado o sin organización") + + if self.request.user.is_superuser: + # Allow superuser to update without organization + serializer.save() + return + + if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + serializer.save(organizacion=self.request.user.organizacion) + return + + raise ValueError("No cuentas con los permisos necesarios para actualizar un DataStage") + + @action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage') + def download_datastage(self, request, pk=None): + """ + Endpoint para descargar el archivo asociado a un DataStage. + """ + try: + datastage = self.get_object() + if not datastage.archivo: + raise Http404("No hay archivo asociado a este DataStage.") + file_path = datastage.archivo.path + if not os.path.exists(file_path): + raise Http404("El archivo no existe en el servidor.") + response = FileResponse(open(file_path, 'rb'), as_attachment=True, filename=os.path.basename(file_path)) + return response + except Exception as e: + return Response({'detail': str(e)}, status=404) + + @action(detail=True, methods=['post'], url_path='procesar') + def procesar(self, request, pk=None): + """ + Endpoint para procesar el DataStage de forma asíncrona usando Celery. + """ + from api.datastage.tasks import procesar_datastage_task + datastage = self.get_object() + user_organizacion = getattr(self.request.user, 'organizacion', None) + user_organizacion_id = user_organizacion.id if user_organizacion else None + task = procesar_datastage_task.delay(datastage.id, user_organizacion_id) + return Response({ + 'task_id': task.id, + 'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.' + }) + + @action(detail=False, methods=['get'], url_path='task-status') + def task_status(self, request): + """ + Consulta el estado de una tarea de Celery por task_id. + """ + from celery.result import AsyncResult + task_id = request.query_params.get('task_id') + if not task_id: + return Response({'detail': 'Falta el parámetro task_id.'}, status=400) + result = AsyncResult(task_id) + return Response({ + 'task_id': task_id, + 'status': result.status, + 'result': result.result if result.successful() else None + }) diff --git a/api/licence/__init__.py b/api/licence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/licence/admin.py b/api/licence/admin.py new file mode 100644 index 0000000..578e4ec --- /dev/null +++ b/api/licence/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import Licencia +# Register your models here. + +class LicenciaAdmin(admin.ModelAdmin): + list_display = ('id', 'nombre') + search_fields = ('nombre',) + list_filter = ('nombre',) + +admin.site.register(Licencia, LicenciaAdmin) \ No newline at end of file diff --git a/api/licence/apps.py b/api/licence/apps.py new file mode 100644 index 0000000..fefd05c --- /dev/null +++ b/api/licence/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LicenceConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.licence' diff --git a/api/licence/migrations/0001_initial.py b/api/licence/migrations/0001_initial.py new file mode 100644 index 0000000..780215c --- /dev/null +++ b/api/licence/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Licencia', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre', models.CharField(max_length=100)), + ('descripcion', models.TextField(blank=True, null=True)), + ('almacenamiento', models.PositiveIntegerField(default=0)), + ], + options={ + 'verbose_name': 'Licencia', + 'verbose_name_plural': 'Licencias', + 'db_table': 'licencia', + }, + ), + ] diff --git a/api/licence/migrations/__init__.py b/api/licence/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/licence/models.py b/api/licence/models.py new file mode 100644 index 0000000..a2952b6 --- /dev/null +++ b/api/licence/models.py @@ -0,0 +1,20 @@ +from django.db import models + +# Create your models here. +class Licencia(models.Model): + # Define the Licencia model minimally for ForeignKey reference + # Add fields as needed + nombre = models.CharField(max_length=100) + descripcion = models.TextField(blank=True, null=True) + almacenamiento = models.PositiveIntegerField(default=0, blank=False, null=False) # in GB + + + class Meta: + verbose_name = "Licencia" + verbose_name_plural = "Licencias" + db_table = 'licencia' + + + def __str__(self): + return self.nombre + \ No newline at end of file diff --git a/api/licence/serializers.py b/api/licence/serializers.py new file mode 100644 index 0000000..4d2247b --- /dev/null +++ b/api/licence/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from .models import Licencia + +class LicenciaSerializer(serializers.ModelSerializer): + class Meta: + model = Licencia + fields = '__all__' + read_only_fields = ('created_at', 'updated_at') \ No newline at end of file diff --git a/api/licence/tests.py b/api/licence/tests.py new file mode 100644 index 0000000..f70c1de --- /dev/null +++ b/api/licence/tests.py @@ -0,0 +1,53 @@ + +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from .models import Licencia + +User = get_user_model() + +class LicenciaViewSetTests(APITestCase): + def setUp(self): + self.superuser = User.objects.create_superuser(username="superuser", password="superpass") + self.user = User.objects.create_user(username="user", password="userpass") + self.client = APIClient() + + def test_superuser_can_list_licencias(self): + Licencia.objects.create(nombre="Lic1", descripcion="desc1", almacenamiento=10) + self.client.force_authenticate(user=self.superuser) + url = reverse('licencia-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(len(response.data) >= 1) + + def test_superuser_can_create_licencia(self): + self.client.force_authenticate(user=self.superuser) + url = reverse('licencia-list') + data = {"nombre": "Lic2", "descripcion": "desc2", "almacenamiento": 20} + response = self.client.post(url, data) + self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) + + def test_superuser_can_update_licencia(self): + lic = Licencia.objects.create(nombre="Lic3", descripcion="desc3", almacenamiento=30) + self.client.force_authenticate(user=self.superuser) + url = reverse('licencia-detail', args=[lic.id]) + data = {"descripcion": "updated"} + response = self.client.patch(url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['descripcion'], "updated") + + def test_user_cannot_create_licencia(self): + self.client.force_authenticate(user=self.user) + url = reverse('licencia-list') + data = {"nombre": "Lic4", "descripcion": "desc4", "almacenamiento": 40} + response = self.client.post(url, data) + self.assertNotIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) + + def test_user_cannot_update_licencia(self): + lic = Licencia.objects.create(nombre="Lic5", descripcion="desc5", almacenamiento=50) + self.client.force_authenticate(user=self.user) + url = reverse('licencia-detail', args=[lic.id]) + data = {"descripcion": "updated"} + response = self.client.patch(url, data) + self.assertNotIn(response.status_code, [status.HTTP_200_OK]) diff --git a/api/licence/urls.py b/api/licence/urls.py new file mode 100644 index 0000000..7b18661 --- /dev/null +++ b/api/licence/urls.py @@ -0,0 +1,22 @@ +# This file defines the URL patterns for the customs app in a Django project. +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +# import necessary viewsets +# from .views import YourViewSet # Import your viewsets here +from .views import ViewSetLicencia +# Create a router and register your viewsets with it + +router = DefaultRouter() + +# Register your viewsets with the router here +# Example: +# from .views import MyViewSet +# router.register(r'myviewset', MyViewSet, basename='myviewset') +router.register(r'licencias', ViewSetLicencia, basename='licencia') + +# Import your viewsets here + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/api/licence/views.py b/api/licence/views.py new file mode 100644 index 0000000..0e04dd3 --- /dev/null +++ b/api/licence/views.py @@ -0,0 +1,61 @@ +from django.shortcuts import render +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated + +from core.permissions import IsSuperUser +from .models import Licencia +from .serializers import LicenciaSerializer + +from api.logger.mixins import LoggingMixin +# Create your views here. + +class ViewSetLicencia(LoggingMixin, viewsets.ModelViewSet): + """ + ViewSet for Licencia model. + """ + permission_classes = [IsAuthenticated, IsSuperUser] + + queryset = Licencia.objects.all() + serializer_class = LicenciaSerializer + filterset_fields = ['nombre', 'descripcion', 'fecha_emision'] + + my_tags = ['Licencias'] + + def perform_create(self, serializer): + """ + Override to add custom logic for creating a Licencia. + """ + if not self.request.user.is_authenticated: + raise ValueError("Usuario no autenticado") + + # If superuser, allow creating without organization + if self.request.user.is_superuser: + serializer.save() + else: + raise ValueError("Solo los superusuarios pueden crear licencias sin organización asignada") + + def perform_update(self, serializer): + """ + Override to add custom logic for updating a Licencia. + """ + if not self.request.user.is_authenticated: + raise ValueError("Usuario no autenticado") + + # If superuser, allow updating without organization + if self.request.user.is_superuser: + serializer.save() + else: + raise ValueError("Solo los superusuarios pueden actualizar licencias sin organización asignada") + + def perform_destroy(self, instance): + """ + Override to add custom logic for deleting a Licencia. + """ + if not self.request.user.is_authenticated: + raise ValueError("Usuario no autenticado") + + # If superuser, allow deleting without organization + if self.request.user.is_superuser: + instance.delete() + else: + raise ValueError("Solo los superusuarios pueden eliminar licencias sin organización asignada") diff --git a/api/logger/__init__.py b/api/logger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/logger/admin.py b/api/logger/admin.py new file mode 100644 index 0000000..83a0282 --- /dev/null +++ b/api/logger/admin.py @@ -0,0 +1,149 @@ + +from django.contrib import admin +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from .models import RequestLog, UserActivity, ErrorLog +import json +from config.settings import SITE_URL + +class ReadOnlyAdminMixin: + """Mixin para hacer que los modelos sean solo lectura en el admin.""" + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(RequestLog) +class RequestLogAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): + list_display = [ + 'timestamp', 'user_display', 'method', 'path', 'status_code', + 'response_time', 'ip_address' + ] + list_filter = [ + 'method', 'status_code', 'timestamp', 'user_agent' + ] + search_fields = [ + 'path', 'ip_address', 'user__username', 'user__email' + ] + readonly_fields = [ + 'timestamp', 'user', 'method', 'path', 'query_params_display', + 'status_code', 'response_time', 'ip_address', 'user_agent', + 'body_display', 'referer' + ] + ordering = ['-timestamp'] + date_hierarchy = 'timestamp' + list_per_page = 50 + + def user_display(self, obj): + if obj.user: + return f"{obj.user.username} ({obj.user.email})" + return "Anónimo" + user_display.short_description = "Usuario" + + def query_params_display(self, obj): + if obj.query_params: + try: + params = json.loads(obj.query_params) if isinstance(obj.query_params, str) else obj.query_params + formatted = json.dumps(params, indent=2, ensure_ascii=False) + return format_html('
{}
', formatted) + except: + return obj.query_params + return "Sin parámetros" + query_params_display.short_description = "Parámetros de consulta" + + def body_display(self, obj): + if obj.body: + try: + # Intentar formatear como JSON si es posible + body_data = json.loads(obj.body) if isinstance(obj.body, str) else obj.body + formatted = json.dumps(body_data, indent=2, ensure_ascii=False) + return format_html('
{}
', formatted) + except: + # Si no es JSON válido, mostrar como texto + return format_html('
{}
', obj.body[:1000]) + return "Sin body" + body_display.short_description = "Cuerpo del request" + + +@admin.register(UserActivity) +class UserActivityAdmin(admin.ModelAdmin): + list_display = [ + 'timestamp', 'user_display', 'action', 'object_type', + 'object_id', 'ip_address' + ] + list_filter = [ + 'action', 'object_type', 'timestamp' + ] + search_fields = [ + 'user__username', 'user__email', 'action', 'object_type', + 'object_id', 'ip_address', 'description' + ] + readonly_fields = [ + 'timestamp', 'user', 'action', 'object_type', 'object_id', + 'description', 'ip_address' + ] + ordering = ['-timestamp'] + date_hierarchy = 'timestamp' + list_per_page = 50 + + def user_display(self, obj): + if obj.user: + return f"{obj.user.username} ({obj.user.email})" + return "Sistema" + user_display.short_description = "Usuario" + + +@admin.register(ErrorLog) +class ErrorLogAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): + list_display = [ + 'timestamp', 'user_display', 'level', 'message_short', + 'request_path' + ] + list_filter = [ + 'level', 'timestamp' + ] + search_fields = [ + 'user__username', 'user__email', 'level', 'message', + 'request_path', 'traceback' + ] + readonly_fields = [ + 'timestamp', 'user', 'level', 'message', 'traceback_display', + 'request_path', 'ip_address' + ] + ordering = ['-timestamp'] + date_hierarchy = 'timestamp' + list_per_page = 25 + + def user_display(self, obj): + if obj.user: + return f"{obj.user.username} ({obj.user.email})" + return "Sistema/Anónimo" + user_display.short_description = "Usuario" + + def message_short(self, obj): + if obj.message and len(obj.message) > 100: + return f"{obj.message[:100]}..." + return obj.message or "Sin mensaje" + message_short.short_description = "Mensaje" + + def traceback_display(self, obj): + if obj.traceback: + return format_html( + '
{}
', + obj.traceback + ) + return "Sin traceback" + traceback_display.short_description = "Stack trace" + + +# Personalización del admin site +admin.site.site_header = "EFC V2 " +admin.site.site_title = "EFC V2" +admin.site.index_title = "Administración del Sistema" +admin.site.site_url = SITE_URL \ No newline at end of file diff --git a/api/logger/apps.py b/api/logger/apps.py new file mode 100644 index 0000000..c2dea3a --- /dev/null +++ b/api/logger/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LoggerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.logger' \ No newline at end of file diff --git a/api/logger/middleware.py b/api/logger/middleware.py new file mode 100644 index 0000000..decd67a --- /dev/null +++ b/api/logger/middleware.py @@ -0,0 +1,91 @@ +import time +import json +import logging +from django.utils.deprecation import MiddlewareMixin +from django.contrib.auth.models import AnonymousUser +from .models import RequestLog, ErrorLog + +logger = logging.getLogger('django') + +class RequestLoggingMiddleware(MiddlewareMixin): + def process_request(self, request): + request.start_time = time.time() + return None + + def process_response(self, request, response): + # Calcular tiempo de respuesta + response_time = (time.time() - getattr(request, 'start_time', 0)) * 1000 + + # Obtener información del usuario + user = request.user if not isinstance(request.user, AnonymousUser) else None + + # Obtener IP del cliente + ip_address = self.get_client_ip(request) + + # Obtener query parameters + query_params = dict(request.GET) if request.GET else {} + + # Obtener body de la request (solo para POST, PUT, PATCH) + body = "" + if request.method in ['POST', 'PUT', 'PATCH']: + try: + if hasattr(request, 'body'): + body = request.body.decode('utf-8')[:1000] # Limitar a 1000 caracteres + except Exception: + body = "Could not decode body" + + # Crear log de la request + try: + RequestLog.objects.create( + user=user, + ip_address=ip_address, + user_agent=request.META.get('HTTP_USER_AGENT', '')[:500], + method=request.method, + path=request.path, + query_params=json.dumps(query_params), + body=body, + status_code=response.status_code, + response_time=response_time, + referer=request.META.get('HTTP_REFERER', '') + ) + except Exception as e: + logger.error(f"Error logging request: {e}") + + return response + + def get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + +class ErrorLoggingMiddleware(MiddlewareMixin): + def process_exception(self, request, exception): + import traceback + + user = request.user if not isinstance(request.user, AnonymousUser) else None + ip_address = self.get_client_ip(request) + + try: + ErrorLog.objects.create( + level='ERROR', + message=str(exception), + traceback=traceback.format_exc(), + user=user, + ip_address=ip_address, + request_path=request.path + ) + except Exception as e: + logger.error(f"Error logging exception: {e}") + + return None + + def get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip \ No newline at end of file diff --git a/api/logger/migrations/0001_initial.py b/api/logger/migrations/0001_initial.py new file mode 100644 index 0000000..1f64464 --- /dev/null +++ b/api/logger/migrations/0001_initial.py @@ -0,0 +1,73 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ErrorLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.CharField(choices=[('DEBUG', 'Debug'), ('INFO', 'Info'), ('WARNING', 'Warning'), ('ERROR', 'Error'), ('CRITICAL', 'Critical')], max_length=10)), + ('message', models.TextField()), + ('traceback', models.TextField(blank=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('request_path', models.URLField(blank=True, max_length=500)), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'logger_error_log', + 'ordering': ['-timestamp'], + }, + ), + migrations.CreateModel( + name='RequestLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.GenericIPAddressField()), + ('user_agent', models.TextField(blank=True)), + ('method', models.CharField(choices=[('GET', 'GET'), ('POST', 'POST'), ('PUT', 'PUT'), ('PATCH', 'PATCH'), ('DELETE', 'DELETE'), ('OPTIONS', 'OPTIONS'), ('HEAD', 'HEAD')], max_length=10)), + ('path', models.URLField(max_length=500)), + ('query_params', models.TextField(blank=True)), + ('body', models.TextField(blank=True)), + ('status_code', models.IntegerField()), + ('response_time', models.FloatField()), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('referer', models.URLField(blank=True, max_length=500)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'logger_request_log', + 'ordering': ['-timestamp'], + }, + ), + migrations.CreateModel( + name='UserActivity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('login', 'Login'), ('logout', 'Logout'), ('create', 'Create'), ('update', 'Update'), ('delete', 'Delete'), ('view', 'View'), ('search', 'Search'), ('export', 'Export'), ('import', 'Import')], max_length=20)), + ('object_type', models.CharField(blank=True, max_length=100)), + ('object_id', models.CharField(blank=True, max_length=100)), + ('description', models.TextField(blank=True)), + ('ip_address', models.GenericIPAddressField()), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'logger_user_activity', + 'ordering': ['-timestamp'], + }, + ), + ] diff --git a/api/logger/migrations/__init__.py b/api/logger/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/logger/mixins.py b/api/logger/mixins.py new file mode 100644 index 0000000..e34c195 --- /dev/null +++ b/api/logger/mixins.py @@ -0,0 +1,103 @@ +from .utils import log_user_activity + +class LoggingMixin: + """ + Mixin para añadir logging automático a ViewSets + """ + log_actions = True + log_object_type = None + + def get_log_object_type(self): + """Obtiene el tipo de objeto del modelo del ViewSet""" + if self.log_object_type: + return self.log_object_type + + if hasattr(self, 'queryset') and self.queryset is not None: + return self.queryset.model.__name__ + + if hasattr(self, 'model') and self.model is not None: + return self.model.__name__ + + return self.__class__.__name__.replace('ViewSet', '') + + def perform_create(self, serializer): + """Override para loggear creaciones""" + instance = serializer.save() + + if self.log_actions and self.request.user.is_authenticated: + log_user_activity( + user=self.request.user, + action='create', + object_type=self.get_log_object_type(), + object_id=instance.pk, + description=f'Creado {self.get_log_object_type()} {instance.pk}', + request=self.request + ) + + return instance + + def perform_update(self, serializer): + """Override para loggear actualizaciones""" + instance = serializer.save() + + if self.log_actions and self.request.user.is_authenticated: + log_user_activity( + user=self.request.user, + action='update', + object_type=self.get_log_object_type(), + object_id=instance.pk, + description=f'Actualizado {self.get_log_object_type()} {instance.pk}', + request=self.request + ) + + return instance + + def perform_destroy(self, instance): + """Override para loggear eliminaciones""" + object_id = instance.pk + object_type = self.get_log_object_type() + + instance.delete() + + if self.log_actions and self.request.user.is_authenticated: + log_user_activity( + user=self.request.user, + action='delete', + object_type=object_type, + object_id=object_id, + description=f'Eliminado {object_type} {object_id}', + request=self.request + ) + + def retrieve(self, request, *args, **kwargs): + """Override para loggear visualizaciones de detalle""" + response = super().retrieve(request, *args, **kwargs) + + if self.log_actions and request.user.is_authenticated: + instance = self.get_object() + log_user_activity( + user=request.user, + action='view', + object_type=self.get_log_object_type(), + object_id=instance.pk, + description=f'Visto detalle de {self.get_log_object_type()} {instance.pk}', + request=request + ) + + return response + + def list(self, request, *args, **kwargs): + """Override para loggear listados""" + response = super().list(request, *args, **kwargs) + + if self.log_actions and request.user.is_authenticated: + log_user_activity( + user=request.user, + action='view', + object_type=self.get_log_object_type(), + object_id='', + description=f'Visto listado de {self.get_log_object_type()}', + request=request + ) + + return response \ No newline at end of file diff --git a/api/logger/models.py b/api/logger/models.py new file mode 100644 index 0000000..d0e5f16 --- /dev/null +++ b/api/logger/models.py @@ -0,0 +1,89 @@ +from django.db import models +from api.cuser.models import CustomUser as User # Asegúrate de que este es el modelo de usuario correcto +from django.utils import timezone + +class RequestLog(models.Model): + METHODS = ( + ('GET', 'GET'), + ('POST', 'POST'), + ('PUT', 'PUT'), + ('PATCH', 'PATCH'), + ('DELETE', 'DELETE'), + ('OPTIONS', 'OPTIONS'), + ('HEAD', 'HEAD'), + ) + + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + ip_address = models.GenericIPAddressField() + user_agent = models.TextField(blank=True) + method = models.CharField(max_length=10, choices=METHODS) + path = models.URLField(max_length=500) + query_params = models.TextField(blank=True) + body = models.TextField(blank=True) + status_code = models.IntegerField() + response_time = models.FloatField() # en milisegundos + timestamp = models.DateTimeField(default=timezone.now) + referer = models.URLField(max_length=500, blank=True) + + class Meta: + db_table = 'logger_request_log' + ordering = ['-timestamp'] + + def __str__(self): + return f"{self.method} {self.path} - {self.status_code} ({self.timestamp})" + +class UserActivity(models.Model): + ACTIONS = ( + ('login', 'Login'), + ('logout', 'Logout'), + ('create', 'Create'), + ('update', 'Update'), + ('delete', 'Delete'), + ('view', 'View'), + ('search', 'Search'), + ('export', 'Export'), + ('import', 'Import'), + ) + + user = models.ForeignKey(User, on_delete=models.CASCADE) + action = models.CharField(max_length=20, choices=ACTIONS) + object_type = models.CharField(max_length=100, blank=True) # modelo afectado + object_id = models.CharField(max_length=100, blank=True) # ID del objeto + description = models.TextField(blank=True) + ip_address = models.GenericIPAddressField() + timestamp = models.DateTimeField(default=timezone.now) + + class Meta: + db_table = 'logger_user_activity' + ordering = ['-timestamp'] + + def __str__(self): + return f"{self.user.username} - {self.action} ({self.timestamp})" + +class ErrorLog(models.Model): + ERROR_LEVELS = ( + ('DEBUG', 'Debug'), + ('INFO', 'Info'), + ('WARNING', 'Warning'), + ('ERROR', 'Error'), + ('CRITICAL', 'Critical'), + ) + + level = models.CharField(max_length=10, choices=ERROR_LEVELS) + message = models.TextField() + traceback = models.TextField(blank=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + request_path = models.URLField(max_length=500, blank=True) + timestamp = models.DateTimeField(default=timezone.now) + + class Meta: + db_table = 'logger_error_log' + ordering = ['-timestamp'] + + def __str__(self): + return f"{self.level}: {self.message[:50]}... ({self.timestamp})" + + + + diff --git a/api/logger/serializers.py b/api/logger/serializers.py new file mode 100644 index 0000000..4a47239 --- /dev/null +++ b/api/logger/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers +from .models import RequestLog, UserActivity, ErrorLog +from api.cuser.models import CustomUser + +class UserSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ['id', 'username', 'first_name', 'last_name', 'email'] + +class RequestLogSerializer(serializers.ModelSerializer): + user = UserSimpleSerializer(read_only=True) + + class Meta: + model = RequestLog + fields = '__all__' + +class UserActivitySerializer(serializers.ModelSerializer): + user = UserSimpleSerializer(read_only=True) + + class Meta: + model = UserActivity + fields = '__all__' + +class ErrorLogSerializer(serializers.ModelSerializer): + user = UserSimpleSerializer(read_only=True) + + class Meta: + model = ErrorLog + fields = '__all__' \ No newline at end of file diff --git a/api/logger/tests.py b/api/logger/tests.py new file mode 100644 index 0000000..c2629a3 --- /dev/null +++ b/api/logger/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. \ No newline at end of file diff --git a/api/logger/urls.py b/api/logger/urls.py new file mode 100644 index 0000000..cb84b59 --- /dev/null +++ b/api/logger/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import RequestLogViewSet, UserActivityViewSet, ErrorLogViewSet + +router = DefaultRouter() +router.register(r'requests', RequestLogViewSet) +router.register(r'activities', UserActivityViewSet) +router.register(r'errors', ErrorLogViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/api/logger/utils.py b/api/logger/utils.py new file mode 100644 index 0000000..2782264 --- /dev/null +++ b/api/logger/utils.py @@ -0,0 +1,98 @@ +from django.contrib.auth.models import User +from .models import UserActivity, ErrorLog +import logging + +def get_client_ip(request): + """Obtiene la IP real del cliente""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + +def log_user_activity(user, action, object_type='', object_id='', description='', request=None): + """ + Registra actividad del usuario + + Args: + user: Usuario que realiza la acción + action: Tipo de acción (login, logout, create, update, delete, view, search, export, import) + object_type: Tipo de objeto afectado (opcional) + object_id: ID del objeto afectado (opcional) + description: Descripción adicional (opcional) + request: Request object para obtener IP (opcional) + """ + ip_address = '127.0.0.1' + if request: + ip_address = get_client_ip(request) + + try: + UserActivity.objects.create( + user=user, + action=action, + object_type=object_type, + object_id=str(object_id) if object_id else '', + description=description, + ip_address=ip_address + ) + except Exception as e: + logging.error(f"Error logging user activity: {e}") + +def log_error(level, message, traceback='', user=None, request=None): + """ + Registra errores personalizados + + Args: + level: Nivel del error (DEBUG, INFO, WARNING, ERROR, CRITICAL) + message: Mensaje del error + traceback: Traceback del error (opcional) + user: Usuario relacionado (opcional) + request: Request object (opcional) + """ + ip_address = None + request_path = '' + + if request: + ip_address = get_client_ip(request) + request_path = request.path + + try: + ErrorLog.objects.create( + level=level, + message=message, + traceback=traceback, + user=user, + ip_address=ip_address, + request_path=request_path + ) + except Exception as e: + logging.error(f"Error logging custom error: {e}") + +# Decorador para loggear automáticamente acciones +def log_action(action, object_type=''): + """ + Decorador para loggear automáticamente acciones en vistas + + Usage: + @log_action('create', 'Pedimento') + def create_pedimento(request): + # tu código aquí + """ + def decorator(func): + def wrapper(request, *args, **kwargs): + result = func(request, *args, **kwargs) + + if hasattr(request, 'user') and request.user.is_authenticated: + object_id = kwargs.get('pk', kwargs.get('id', '')) + log_user_activity( + user=request.user, + action=action, + object_type=object_type, + object_id=object_id, + request=request + ) + + return result + return wrapper + return decorator \ No newline at end of file diff --git a/api/logger/views.py b/api/logger/views.py new file mode 100644 index 0000000..2e62c9d --- /dev/null +++ b/api/logger/views.py @@ -0,0 +1,92 @@ +from rest_framework import viewsets, filters +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from django_filters.rest_framework import DjangoFilterBackend +from django.db.models import Count, Q +from django.utils import timezone +from datetime import timedelta +from .models import RequestLog, UserActivity, ErrorLog +from .serializers import RequestLogSerializer, UserActivitySerializer, ErrorLogSerializer +from .utils import log_user_activity + +from core.permissions import IsSuperUser + +class RequestLogViewSet(viewsets.ReadOnlyModelViewSet): + queryset = RequestLog.objects.all() + serializer_class = RequestLogSerializer + permission_classes = [IsAuthenticated, IsSuperUser] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + + filterset_fields = ['method', 'status_code', 'user'] + search_fields = ['path', 'ip_address', 'user_agent'] + ordering_fields = ['timestamp', 'response_time', 'status_code'] + ordering = ['-timestamp'] + + @action(detail=False, methods=['get']) + def statistics(self, request): + """Estadísticas de requests""" + now = timezone.now() + today = now.date() + week_ago = now - timedelta(days=7) + + stats = { + 'total_requests': self.queryset.count(), + 'today_requests': self.queryset.filter(timestamp__date=today).count(), + 'week_requests': self.queryset.filter(timestamp__gte=week_ago).count(), + 'methods': self.queryset.values('method').annotate(count=Count('method')), + 'status_codes': self.queryset.values('status_code').annotate(count=Count('status_code')), + 'top_endpoints': self.queryset.values('path').annotate(count=Count('path')).order_by('-count')[:10], + 'avg_response_time': self.queryset.aggregate(avg_time=Count('response_time'))['avg_time'] + } + + return Response(stats) + +class UserActivityViewSet(viewsets.ReadOnlyModelViewSet): + queryset = UserActivity.objects.all() + serializer_class = UserActivitySerializer + permission_classes = [IsAuthenticated, IsSuperUser] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + filterset_fields = ['action', 'user', 'object_type'] + search_fields = ['description', 'object_id'] + ordering_fields = ['timestamp'] + ordering = ['-timestamp'] + + def get_queryset(self): + # Check if user is authenticated first + if not self.request.user.is_authenticated: + return UserActivity.objects.none() + + # Los usuarios normales solo ven su propia actividad + if self.request.user.is_staff: + return UserActivity.objects.all() + return UserActivity.objects.filter(user=self.request.user) + + @action(detail=False, methods=['get']) + def my_activity(self, request): + """Actividad del usuario actual""" + if not request.user.is_authenticated: + return Response({"error": "Usuario no autenticado"}, status=401) + + activities = UserActivity.objects.filter(user=request.user)[:20] + serializer = self.get_serializer(activities, many=True) + return Response(serializer.data) + +class ErrorLogViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ErrorLog.objects.all() + serializer_class = ErrorLogSerializer + permission_classes = [IsAuthenticated, IsSuperUser] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + filterset_fields = ['level', 'user'] + search_fields = ['message', 'request_path'] + ordering_fields = ['timestamp'] + ordering = ['-timestamp'] + + @action(detail=False, methods=['get']) + def recent_errors(self, request): + """Errores recientes (últimas 24 horas)""" + yesterday = timezone.now() - timedelta(days=1) + recent_errors = self.queryset.filter(timestamp__gte=yesterday) + serializer = self.get_serializer(recent_errors, many=True) + return Response(serializer.data) \ No newline at end of file diff --git a/api/notificaciones/__init__.py b/api/notificaciones/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/notificaciones/admin.py b/api/notificaciones/admin.py new file mode 100644 index 0000000..33bbcbd --- /dev/null +++ b/api/notificaciones/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from .models import Notificacion, TipoNotificacion +# Register your models here. + + +class NotificacionAdmin(admin.ModelAdmin): + list_display = ('tipo', 'dirigido', 'mensaje', 'fecha_envio', 'created_at', 'visto') + search_fields = ('mensaje', 'tipo__tipo', 'dirigido__username') + list_filter = ('tipo', 'visto', 'fecha_envio') + ordering = ('-created_at',) + date_hierarchy = 'fecha_envio' + +class TipoNotificacionAdmin(admin.ModelAdmin): + list_display = ('tipo', 'descripcion') + search_fields = ('tipo',) + ordering = ('tipo',) + +admin.site.register(Notificacion, NotificacionAdmin) +admin.site.register(TipoNotificacion, TipoNotificacionAdmin) +admin.site.empty_value_display = '-vacío-' # Display this when a field is empty diff --git a/api/notificaciones/apps.py b/api/notificaciones/apps.py new file mode 100644 index 0000000..8c5cf3b --- /dev/null +++ b/api/notificaciones/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class NotificacionesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.notificaciones' + + def ready(self): + import api.notificaciones.signals.notificaciones + # Import other signals if needed + # import api.notificaciones.signals.other_signal \ No newline at end of file diff --git a/api/notificaciones/consumers.py b/api/notificaciones/consumers.py new file mode 100644 index 0000000..e69de29 diff --git a/api/notificaciones/migrations/0001_initial.py b/api/notificaciones/migrations/0001_initial.py new file mode 100644 index 0000000..96c75f4 --- /dev/null +++ b/api/notificaciones/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TipoNotificacion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tipo', models.CharField(help_text='Tipo de notificación', max_length=100, unique=True)), + ('descripcion', models.CharField(help_text='Descripción del tipo de notificación', max_length=200)), + ], + options={ + 'verbose_name': 'Tipo de Notificación', + 'verbose_name_plural': 'Tipos de Notificación', + 'db_table': 'tipo_notificacion', + 'ordering': ['tipo'], + }, + ), + migrations.CreateModel( + name='Notificacion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mensaje', models.TextField(help_text='Mensaje de la notificación')), + ('fecha_envio', models.DateTimeField(blank=True, help_text='Fecha de envío de la notificación', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Fecha de creación de la notificación')), + ('visto', models.BooleanField(default=False, help_text='Indica si la notificación ha sido vista')), + ('dirigido', models.ForeignKey(help_text='Usuario al que se dirige la notificación', on_delete=django.db.models.deletion.CASCADE, related_name='notificaciones', to=settings.AUTH_USER_MODEL)), + ('tipo', models.ForeignKey(help_text='Tipo de notificación', on_delete=django.db.models.deletion.CASCADE, related_name='notificaciones', to='notificaciones.tiponotificacion')), + ], + options={ + 'verbose_name': 'Notificación', + 'verbose_name_plural': 'Notificaciones', + 'db_table': 'notificaciones', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/api/notificaciones/migrations/__init__.py b/api/notificaciones/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/notificaciones/models.py b/api/notificaciones/models.py new file mode 100644 index 0000000..61d3933 --- /dev/null +++ b/api/notificaciones/models.py @@ -0,0 +1,35 @@ +from django.db import models +from api.cuser.models import CustomUser +# Create your models here. + +class TipoNotificacion(models.Model): + tipo = models.CharField(max_length=100, unique=True, help_text="Tipo de notificación") + descripcion = models.CharField(max_length=200, help_text="Descripción del tipo de notificación") + + def __str__(self): + return self.tipo + + class Meta: + verbose_name = "Tipo de Notificación" + verbose_name_plural = "Tipos de Notificación" + db_table = 'tipo_notificacion' + ordering = ['tipo'] + +class Notificacion(models.Model): + tipo = models.ForeignKey(TipoNotificacion, on_delete=models.CASCADE, related_name='notificaciones', help_text="Tipo de notificación") + dirigido = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='notificaciones', help_text="Usuario al que se dirige la notificación") + + + mensaje = models.TextField(help_text="Mensaje de la notificación") + fecha_envio = models.DateTimeField(blank=True, null=True, help_text="Fecha de envío de la notificación") + created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la notificación") + visto = models.BooleanField(default=False, help_text="Indica si la notificación ha sido vista") + + def __str__(self): + return f"{self.tipo} - {self.created_at.strftime('%Y-%m-%d %H:%M:%S')}" + + class Meta: + verbose_name = "Notificación" + verbose_name_plural = "Notificaciones" + db_table = 'notificaciones' + ordering = ['-created_at'] \ No newline at end of file diff --git a/api/notificaciones/routing.py b/api/notificaciones/routing.py new file mode 100644 index 0000000..e69de29 diff --git a/api/notificaciones/serializers.py b/api/notificaciones/serializers.py new file mode 100644 index 0000000..e09291e --- /dev/null +++ b/api/notificaciones/serializers.py @@ -0,0 +1,25 @@ +from rest_framework import serializers +from .models import Notificacion, TipoNotificacion + +class TipoNotificacionSerializer(serializers.ModelSerializer): + class Meta: + model = TipoNotificacion + fields = ['id', 'tipo', 'descripcion'] + read_only_fields = ['id', 'tipo', 'descripcion'] + + +class NotificacionSerializer(serializers.ModelSerializer): + class Meta: + model = Notificacion + fields = [ + 'id', + 'tipo', + 'dirigido', + 'mensaje', + 'fecha_envio', + 'created_at', + 'visto' + ] + read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje'] + + \ No newline at end of file diff --git a/api/notificaciones/signals/__init__.py b/api/notificaciones/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/notificaciones/signals/notificaciones.py b/api/notificaciones/signals/notificaciones.py new file mode 100644 index 0000000..4878401 --- /dev/null +++ b/api/notificaciones/signals/notificaciones.py @@ -0,0 +1,34 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from api.notificaciones.models import Notificacion +from api.record.models import Document + +@receiver(post_save, sender=Document) +def trigger_notificacion(sender, instance, created, **kwargs): + if created: + from api.cuser.models import CustomUser + from api.customs.models import Pedimento + from api.notificaciones.models import TipoNotificacion + + # Obtener el tipo de notificación (puedes ajustar el nombre si tienes tipos definidos) + tipo_info, _ = TipoNotificacion.objects.get_or_create(tipo="info", defaults={"descripcion": "Notificación informativa"}) + + # Notificar a todos los usuarios de la organización + usuarios_org = CustomUser.objects.filter(organizacion=instance.organizacion) + for usuario in usuarios_org: + # Notificar solo a importadores cuyo RFC coincide + if (usuario.is_importador or usuario.groups.filter(name='Importador').exists()): + if usuario.rfc == instance.pedimento.contribuyente: + Notificacion.objects.create( + tipo=tipo_info, + dirigido=usuario, + mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}", + ) + # Notificar a otros roles (no importadores) + elif (usuario.is_superuser or usuario.groups.filter(name='Agente Aduanal').exists() or usuario.groups.filter(name='admin').exists()): + Notificacion.objects.create( + tipo=tipo_info, + dirigido=usuario, + mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}", + ) \ No newline at end of file diff --git a/api/notificaciones/tests.py b/api/notificaciones/tests.py new file mode 100644 index 0000000..d9c5696 --- /dev/null +++ b/api/notificaciones/tests.py @@ -0,0 +1,77 @@ + +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from .models import Notificacion, TipoNotificacion +from api.organization.models import Organizacion + +User = get_user_model() + +class NotificacionesViewSetTests(APITestCase): + def setUp(self): + self.org = Organizacion.objects.create(nombre="OrgTest", is_active=True, is_verified=True) + self.org2 = Organizacion.objects.create(nombre="OrgTest2", is_active=True, is_verified=True) + self.admin = User.objects.create_user(username="admin", password="adminpass", organizacion=self.org) + self.admin.groups.create(name="admin") + self.superuser = User.objects.create_superuser(username="superuser", password="superpass") + self.importador = User.objects.create_user(username="importador", password="importpass", organizacion=self.org2, is_importador=True, rfc="RFC123456789") + self.importador.groups.create(name="importador") + self.client = APIClient() + + def test_list_tipo_notificacion(self): + TipoNotificacion.objects.create(tipo="info", descripcion="informativa") + self.client.force_authenticate(user=self.admin) + url = reverse('tipo-notificacion-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(len(response.data) >= 1) + + def test_admin_sees_only_org_notificaciones(self): + tipo = TipoNotificacion.objects.create(tipo="info", descripcion="informativa") + notif1 = Notificacion.objects.create(tipo=tipo, dirigido=self.admin, mensaje="msg1") + notif2 = Notificacion.objects.create(tipo=tipo, dirigido=self.importador, mensaje="msg2") + self.client.force_authenticate(user=self.admin) + url = reverse('notificacion-list') + response = self.client.get(url) + mensajes = [n['mensaje'] for n in response.data] + self.assertIn("msg1", mensajes) + self.assertNotIn("msg2", mensajes) + + def test_superuser_sees_all_notificaciones(self): + tipo = TipoNotificacion.objects.create(tipo="info", descripcion="informativa") + notif1 = Notificacion.objects.create(tipo=tipo, dirigido=self.admin, mensaje="msg1") + notif2 = Notificacion.objects.create(tipo=tipo, dirigido=self.importador, mensaje="msg2") + self.client.force_authenticate(user=self.superuser) + url = reverse('notificacion-list') + response = self.client.get(url) + mensajes = [n['mensaje'] for n in response.data] + self.assertIn("msg1", mensajes) + self.assertIn("msg2", mensajes) + + def test_importador_sees_only_own_notificaciones(self): + tipo = TipoNotificacion.objects.create(tipo="info", descripcion="informativa") + notif1 = Notificacion.objects.create(tipo=tipo, dirigido=self.admin, mensaje="msg1") + notif2 = Notificacion.objects.create(tipo=tipo, dirigido=self.importador, mensaje="msg2") + self.client.force_authenticate(user=self.importador) + url = reverse('notificacion-list') + response = self.client.get(url) + mensajes = [n['mensaje'] for n in response.data] + self.assertNotIn("msg1", mensajes) + self.assertIn("msg2", mensajes) + + def test_superuser_can_create_notificacion(self): + tipo = TipoNotificacion.objects.create(tipo="info", descripcion="informativa") + self.client.force_authenticate(user=self.superuser) + url = reverse('notificacion-list') + data = {"tipo": tipo.id, "dirigido": self.admin.id, "mensaje": "msg3"} + response = self.client.post(url, data) + self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) + + def test_admin_cannot_create_notificacion(self): + tipo = TipoNotificacion.objects.create(tipo="info", descripcion="informativa") + self.client.force_authenticate(user=self.admin) + url = reverse('notificacion-list') + data = {"tipo": tipo.id, "dirigido": self.importador.id, "mensaje": "msg4"} + response = self.client.post(url, data) + self.assertNotIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_200_OK]) diff --git a/api/notificaciones/urls.py b/api/notificaciones/urls.py new file mode 100644 index 0000000..5bcd757 --- /dev/null +++ b/api/notificaciones/urls.py @@ -0,0 +1,13 @@ +from rest_framework import routers +from django.urls import path, include +from .views import TipoNotificacionViewSet, NotificacionViewSet + +# Create a router and register the viewsets +router = routers.DefaultRouter() +router.register(r'tipos', TipoNotificacionViewSet, basename='tipo-notificacion') +router.register(r'notificaciones', NotificacionViewSet, basename='notificacion') + +# Create a router and register the healthcheck view +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/api/notificaciones/views.py b/api/notificaciones/views.py new file mode 100644 index 0000000..92f86da --- /dev/null +++ b/api/notificaciones/views.py @@ -0,0 +1,50 @@ +from django.shortcuts import render +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from .models import Notificacion, TipoNotificacion +from .serializers import NotificacionSerializer, TipoNotificacionSerializer +from core.permissions import ( + IsSameOrganization, + IsSameOrganizationDeveloper, + IsSameOrganizationAndAdmin, + IsSuperUser +) +# Create your views here. + +class TipoNotificacionViewSet(viewsets.ModelViewSet): + queryset = TipoNotificacion.objects.all() + serializer_class = TipoNotificacionSerializer + http_method_names = ['get'] + + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + + my_tags = ['Notificaciones'] + + def get_queryset(self): + return self.queryset.order_by('tipo') + +class NotificacionViewSet(viewsets.ModelViewSet): + queryset = Notificacion.objects.all() + serializer_class = NotificacionSerializer + http_method_names = ['get', 'post', 'put', 'patch', 'delete'] + filterset_fields = ['visto'] + + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + my_tags = ['Notificaciones'] + + def get_queryset(self): + # Evita error en generación de esquema Swagger + if getattr(self, 'swagger_fake_view', False): + return Notificacion.objects.none() + user = self.request.user + if not user.is_authenticated: + return Notificacion.objects.none() + return Notificacion.objects.filter(dirigido=user) + + def perform_create(self, serializer): + if not self.request.user.is_authenticated: + raise PermissionDenied("Usuario no autenticado") + if self.request.user.is_superuser: + # Allow superusers and admins to create notifications for any user + serializer.save() + raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios") \ No newline at end of file diff --git a/api/organization/__init__.py b/api/organization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/organization/admin.py b/api/organization/admin.py new file mode 100644 index 0000000..0be0310 --- /dev/null +++ b/api/organization/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from .models import Organizacion +# Register your models here. + +class OrganizacionAdmin(admin.ModelAdmin): + list_display = ('id', 'nombre', 'rfc', 'email', 'telefono', 'is_active', 'is_verified', 'inicia', 'vencimiento') + search_fields = ('nombre', 'rfc', 'email') + list_filter = ('is_active', 'is_verified') + ordering = ('nombre',) + +# class UsuarioOrganizacionAdmin(admin.ModelAdmin): +# list_display = ('id', 'email', 'telefono', 'puesto', 'is_active', 'is_verified') +# search_fields = ('email', 'telefono', 'puesto') +# list_filter = ('is_active', 'is_verified') +# ordering = ('email',) + +admin.site.register(Organizacion) +# admin.site.register(UsuarioOrganizacion) \ No newline at end of file diff --git a/api/organization/apps.py b/api/organization/apps.py new file mode 100644 index 0000000..845fa2f --- /dev/null +++ b/api/organization/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from django.db.models.signals import post_migrate + + +class OrganizationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.organization' + + def ready(self): + import api.organization.signals # noqa \ No newline at end of file diff --git a/api/organization/migrations/0001_initial.py b/api/organization/migrations/0001_initial.py new file mode 100644 index 0000000..5a836b4 --- /dev/null +++ b/api/organization/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('licence', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Organizacion', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_agente_aduanal', models.BooleanField(default=False)), + ('nombre', models.CharField(max_length=100)), + ('rfc', models.CharField(max_length=25)), + ('titular', models.CharField(max_length=200)), + ('email', models.EmailField(max_length=100)), + ('telefono', models.CharField(max_length=25)), + ('estado', models.CharField(max_length=50)), + ('ciudad', models.CharField(max_length=50)), + ('is_active', models.BooleanField(default=True)), + ('is_verified', models.BooleanField(default=False)), + ('inicio', models.DateField(blank=True, null=True)), + ('vencimiento', models.DateField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('observaciones', models.TextField(blank=True, null=True)), + ('membretado', models.ImageField(blank=True, null=True, upload_to='membretado/')), + ('membretado_2', models.ImageField(blank=True, null=True, upload_to='membretado/')), + ('licencia', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizaciones', to='licence.licencia')), + ], + options={ + 'verbose_name': 'Organizacion', + 'verbose_name_plural': 'Organizaciones', + 'db_table': 'organizacion', + 'ordering': ['nombre'], + }, + ), + migrations.CreateModel( + name='UsoAlmacenamiento', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('espacio_utilizado', models.PositiveBigIntegerField(default=0)), + ('organizacion', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='organization.organizacion')), + ], + options={ + 'verbose_name': 'Uso de Almacenamiento', + 'verbose_name_plural': 'Usos de Almacenamiento', + 'db_table': 'uso_almacenamiento', + }, + ), + ] diff --git a/api/organization/migrations/0002_remove_organizacion_membretado_and_more.py b/api/organization/migrations/0002_remove_organizacion_membretado_and_more.py new file mode 100644 index 0000000..aad2ed1 --- /dev/null +++ b/api/organization/migrations/0002_remove_organizacion_membretado_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.3 on 2025-07-14 17:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organization', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='organizacion', + name='membretado', + ), + migrations.RemoveField( + model_name='organizacion', + name='membretado_2', + ), + migrations.CreateModel( + name='OrganizacionConfiguracion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('membretado', models.ImageField(blank=True, null=True, upload_to='membretado/')), + ('membretado_2', models.ImageField(blank=True, null=True, upload_to='membretado/')), + ('organizacion', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='configuracion', to='organization.organizacion')), + ], + options={ + 'verbose_name': 'Configuración de Organización', + 'verbose_name_plural': 'Configuraciones de Organizaciones', + 'db_table': 'organizacion_configuracion', + }, + ), + ] diff --git a/api/organization/migrations/__init__.py b/api/organization/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/organization/models.py b/api/organization/models.py new file mode 100644 index 0000000..caa49c4 --- /dev/null +++ b/api/organization/models.py @@ -0,0 +1,93 @@ +from django.db import models +from api.licence.models import Licencia +from django.conf import settings +import uuid + +class UsoAlmacenamiento(models.Model): + organizacion = models.OneToOneField('Organizacion', on_delete=models.CASCADE) + espacio_utilizado = models.PositiveBigIntegerField(default=0) # en bytes + + class Meta: + verbose_name = "Uso de Almacenamiento" + verbose_name_plural = "Usos de Almacenamiento" + db_table = 'uso_almacenamiento' + + def __str__(self): + return f"{self.organizacion} - {self.espacio_utilizado} bytes" + + @property + def espacio_disponible(self): + # Convertir GB de la licencia a bytes (1 GB = 1024^3 bytes) + max_almacenamiento_bytes = self.organizacion.licencia.almacenamiento * 1024 ** 3 + return max_almacenamiento_bytes - self.espacio_utilizado + + @property + def porcentaje_utilizado(self): + max_almacenamiento_bytes = self.organizacion.licencia.almacenamiento * 1024 ** 3 + if max_almacenamiento_bytes == 0: + return 0 + return (self.espacio_utilizado / max_almacenamiento_bytes) * 100 + +class Organizacion(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + licencia = models.ForeignKey(Licencia, on_delete=models.CASCADE, related_name='organizaciones') + is_agente_aduanal = models.BooleanField(default=False) + nombre = models.CharField(max_length=100) + rfc = models.CharField(max_length=25) + titular = models.CharField(max_length=200) + email = models.EmailField(max_length=100) + telefono = models.CharField(max_length=25) + estado = models.CharField(max_length=50) + ciudad = models.CharField(max_length=50) + + is_active = models.BooleanField(default=True) + is_verified = models.BooleanField(default=False) + + inicio = models.DateField(null=True, blank=True) + vencimiento = models.DateField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + observaciones = models.TextField(null=True, blank=True) + + + @property + def espacio_utilizado(self): + uso, created = UsoAlmacenamiento.objects.get_or_create(organizacion=self) + return uso.espacio_utilizado + + @property + def espacio_disponible(self): + uso, created = UsoAlmacenamiento.objects.get_or_create(organizacion=self) + return (self.licencia.almacenamiento * 1024 ** 3) - uso.espacio_utilizado + + @property + def porcentaje_utilizado(self): + uso, created = UsoAlmacenamiento.objects.get_or_create(organizacion=self) + if self.licencia.almacenamiento == 0: + return 0 + return (uso.espacio_utilizado / (self.licencia.almacenamiento * 1024 ** 3)) * 100 + + def __str__(self): + return self.nombre + + class Meta: + verbose_name = "Organizacion" + verbose_name_plural = "Organizaciones" + db_table = 'organizacion' + ordering = ['nombre'] + +class OrganizacionConfiguracion(models.Model): + organizacion = models.OneToOneField(Organizacion, on_delete=models.CASCADE, related_name='configuracion') + membretado = models.ImageField(upload_to='membretado/', null=True, blank=True) + membretado_2 = models.ImageField(upload_to='membretado/', null=True, blank=True) + + + class Meta: + verbose_name = "Configuración de Organización" + verbose_name_plural = "Configuraciones de Organizaciones" + db_table = 'organizacion_configuracion' + + def __str__(self): + return f"Configuración de {self.organizacion.nombre}" \ No newline at end of file diff --git a/api/organization/serializers.py b/api/organization/serializers.py new file mode 100644 index 0000000..1fffc50 --- /dev/null +++ b/api/organization/serializers.py @@ -0,0 +1,15 @@ +from rest_framework import serializers + +from .models import Organizacion, UsoAlmacenamiento#, UsuarioOrganizacion + +class OrganizacionSerializer(serializers.ModelSerializer): + class Meta: + model = Organizacion + fields = '__all__' + read_only_fields = ('created_at', 'updated_at') + +class UsoAlmacenamientoSerializer(serializers.ModelSerializer): + class Meta: + model = UsoAlmacenamiento + fields = '__all__' + read_only_fields = ('created_at', 'updated_at') \ No newline at end of file diff --git a/api/organization/signals.py b/api/organization/signals.py new file mode 100644 index 0000000..d226496 --- /dev/null +++ b/api/organization/signals.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import Organizacion, UsoAlmacenamiento + +@receiver(post_save, sender=Organizacion) +def crear_uso_almacenamiento(sender, instance, created, **kwargs): + if created: + UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0) \ No newline at end of file diff --git a/api/organization/tests.py b/api/organization/tests.py new file mode 100644 index 0000000..e0d216d --- /dev/null +++ b/api/organization/tests.py @@ -0,0 +1,64 @@ + +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from .models import Organizacion, UsoAlmacenamiento +from api.licence.models import Licencia + +User = get_user_model() + +class OrganizationViewSetTests(APITestCase): + def setUp(self): + self.lic = Licencia.objects.create(nombre="LicTest", almacenamiento=100) + self.org = Organizacion.objects.create(nombre="OrgTest", licencia=self.lic, is_active=True, is_verified=True) + self.org2 = Organizacion.objects.create(nombre="OrgTest2", licencia=self.lic, is_active=True, is_verified=True) + self.admin = User.objects.create_user(username="admin", password="adminpass", organizacion=self.org) + self.admin.groups.create(name="admin") + self.superuser = User.objects.create_superuser(username="superuser", password="superpass") + self.importador = User.objects.create_user(username="importador", password="importpass", organizacion=self.org2, is_importador=True, rfc="RFC123456789") + self.importador.groups.create(name="importador") + self.client = APIClient() + + def test_admin_sees_only_own_organization(self): + self.client.force_authenticate(user=self.admin) + url = reverse('Organizacion-list') + response = self.client.get(url) + nombres = [o['nombre'] for o in response.data] + self.assertIn("OrgTest", nombres) + self.assertNotIn("OrgTest2", nombres) + + def test_superuser_sees_all_organizations(self): + self.client.force_authenticate(user=self.superuser) + url = reverse('Organizacion-list') + response = self.client.get(url) + nombres = [o['nombre'] for o in response.data] + self.assertIn("OrgTest", nombres) + self.assertIn("OrgTest2", nombres) + + def test_admin_sees_only_own_storage(self): + UsoAlmacenamiento.objects.create(organizacion=self.org, espacio_utilizado=1000) + UsoAlmacenamiento.objects.create(organizacion=self.org2, espacio_utilizado=2000) + self.client.force_authenticate(user=self.admin) + url = reverse('UsoAlmacenamiento-list') + response = self.client.get(url) + orgs = [u['organizacion'] for u in response.data] + self.assertIn(self.org.id, orgs) + self.assertNotIn(self.org2.id, orgs) + + def test_superuser_sees_all_storage(self): + UsoAlmacenamiento.objects.create(organizacion=self.org, espacio_utilizado=1000) + UsoAlmacenamiento.objects.create(organizacion=self.org2, espacio_utilizado=2000) + self.client.force_authenticate(user=self.superuser) + url = reverse('UsoAlmacenamiento-list') + response = self.client.get(url) + orgs = [u['organizacion'] for u in response.data] + self.assertIn(self.org.id, orgs) + self.assertIn(self.org2.id, orgs) + + def test_importador_cannot_access_storage(self): + UsoAlmacenamiento.objects.create(organizacion=self.org2, espacio_utilizado=2000) + self.client.force_authenticate(user=self.importador) + url = reverse('UsoAlmacenamiento-list') + response = self.client.get(url) + self.assertNotEqual(response.status_code, status.HTTP_200_OK) \ No newline at end of file diff --git a/api/organization/urls.py b/api/organization/urls.py new file mode 100644 index 0000000..5aa904b --- /dev/null +++ b/api/organization/urls.py @@ -0,0 +1,25 @@ +# This file defines the URL patterns for the customs app in a Django project. +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +# import necessary viewsets +# from .views import YourViewSet # Import your viewsets here +from .views import ViewSetOrganizacion, UsoAlmacenamientoViewSet + +# Create a router and register your viewsets with it + +router = DefaultRouter() + +# Register your viewsets with the router here +# Example: +# from .views import MyViewSet +# router.register(r'myviewset', MyViewSet, basename='myviewset') +router.register(r'organizaciones', ViewSetOrganizacion, basename='Organizacion') +router.register(r'uso-almacenamiento', UsoAlmacenamientoViewSet, basename='UsoAlmacenamiento') + +#router.register(r'usuariosorganizaciones', ViewSetUsuarioOrganizacion, basename='UsuarioOrganizacion') +# Import your viewsets here + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/api/organization/views.py b/api/organization/views.py new file mode 100644 index 0000000..7efb795 --- /dev/null +++ b/api/organization/views.py @@ -0,0 +1,122 @@ +from django.db.models import Sum +from rest_framework import viewsets, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import api_view, permission_classes, action +from rest_framework.response import Response + +from api.record.models import Document +from core.permissions import ( + IsSameOrganization, + IsSameOrganizationDeveloper, + IsSameOrganizationAndAdmin, + IsSuperUser +) +from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer +from .models import Organizacion, UsoAlmacenamiento +from api.customs.models import Pedimento +from api.logger.mixins import LoggingMixin +from mixins.filtrado_organizacion import OrganizacionFiltradaMixin + +# Create your views here. + +class ViewSetOrganizacion(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): + """ + ViewSet for Organizacion model. + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + + queryset = Organizacion.objects.all() + serializer_class = OrganizacionSerializer + filterset_fields = ['nombre', 'descripcion'] + + my_tags = ['Organizaciones'] + + def get_queryset(self): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return Organizacion.objects.none() + + if self.request.user.is_superuser: + # Superuser can see all organizations + return Organizacion.objects.all() + + if (self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter('developer').exists() or self.request.user.groups.filter('user')) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + # Importers can only see their own organization + return Organizacion.objects.filter(users=self.request.user) + + if self.request.user.groups.filter(name='importador').exists(): + return Organizacion.objects.filter(users=self.request.user) + + return Organizacion.objects.none() + +class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet): + """ + Vista para consultar el uso de almacenamiento + Solo lectura (GET) ya que la actualización se hace automáticamente + """ + queryset = UsoAlmacenamiento.objects.all() + serializer_class = UsoAlmacenamientoSerializer + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + + my_tags = ['Uso de Almacenamiento'] + + def get_queryset(self): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return UsoAlmacenamiento.objects.none() + + + if self.request.user.is_superuser: + # Superuser can see all storage usage + return UsoAlmacenamiento.objects.all() + + if (self.request.user.groups.filter(name='developer').exists() or + self.request.user.groups.filter(name='admin').exists() or + self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): + # Developers, Admins, and Users can see their organization's storage usage + return UsoAlmacenamiento.objects.filter(organizacion=self.request.user.organizacion) + + if self.request.user.groups.filter(name='importador').exists(): + # Importers can only see their own organization's storage usage + raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.") + + return UsoAlmacenamiento.objects.none() + + @action(detail=False, methods=['get']) + def mi_organizacion(self, request): + + """Obtiene el uso de almacenamiento de la organización del usuario actual""" + organizacion = request.user.organizacion + + # Obtener o crear el registro de uso + uso, created = UsoAlmacenamiento.objects.get_or_create( + organizacion=organizacion, + defaults={'espacio_utilizado': 0} + ) + + # Calcular el total sumando todos los documentos (en bytes) + total_utilizado = Document.objects.filter( + organizacion=organizacion + ).aggregate(total=Sum('size'))['total'] or 0 + + # Sincronizar con el registro de uso (por si hay discrepancias) + if uso.espacio_utilizado != total_utilizado: + uso.espacio_utilizado = total_utilizado + uso.save() + + # Calcular métricas adicionales + max_almacenamiento_bytes = organizacion.licencia.almacenamiento * 1024 ** 3 + porcentaje = (total_utilizado / max_almacenamiento_bytes * 100) if max_almacenamiento_bytes > 0 else 0 + + data = { + 'organizacion': organizacion.nombre, + 'limite_almacenamiento_gb': organizacion.licencia.almacenamiento, + 'espacio_utilizado_bytes': total_utilizado, + 'espacio_utilizado_gb': total_utilizado / (1024 ** 3), + 'espacio_disponible_bytes': max(max_almacenamiento_bytes - total_utilizado, 0), + 'porcentaje_utilizado': round(porcentaje, 2), + 'total_documentos': Document.objects.filter(organizacion=organizacion).count(), + 'total_pedimentos': Pedimento.objects.filter(organizacion=organizacion).count(), + 'total_usuarios': organizacion.users.count() + } + + return Response(data) + \ No newline at end of file diff --git a/api/record/__init__.py b/api/record/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/record/admin.py b/api/record/admin.py new file mode 100644 index 0000000..1c808a2 --- /dev/null +++ b/api/record/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin +from .models import Document, DocumentType, Fuente + +# Register your models here. + +class DocumentAdmin(admin.ModelAdmin): + model = Document + list_display = ('id', 'pedimento', 'archivo', 'size', 'extension', 'created_at', 'updated_at') + search_fields = ('pedimento.pedimento', 'archivo') + list_filter = ('created_at', 'updated_at') + +class DocumentTypeAdmin(admin.ModelAdmin): + model = DocumentType + list_display = ('id', 'nombre', 'descripcion') + search_fields = ('nombre',) + ordering = ('nombre',) + + +class FuenteAdmin(admin.ModelAdmin): + model = Fuente + list_display = ('id', 'nombre', 'descripcion') + search_fields = ('nombre',) + ordering = ('nombre',) + + + +admin.site.register(Document, DocumentAdmin) +admin.site.register(DocumentType, DocumentTypeAdmin) +admin.site.register(Fuente, FuenteAdmin) \ No newline at end of file diff --git a/api/record/apps.py b/api/record/apps.py new file mode 100644 index 0000000..51c9ea7 --- /dev/null +++ b/api/record/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RecordConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.record' \ No newline at end of file diff --git a/api/record/migrations/0001_initial.py b/api/record/migrations/0001_initial.py new file mode 100644 index 0000000..8afd117 --- /dev/null +++ b/api/record/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('customs', '0001_initial'), + ('organization', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DocumentType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre', models.CharField(max_length=100, unique=True)), + ('descripcion', models.TextField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Tipo de Documento', + 'verbose_name_plural': 'Tipos de Documento', + 'db_table': 'document_type', + 'ordering': ['nombre'], + }, + ), + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('archivo', models.FileField(max_length=400, upload_to='documents/')), + ('extension', models.CharField(blank=True, max_length=60, null=True)), + ('size', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('organizacion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='organization.organizacion')), + ('pedimento', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='customs.pedimento')), + ('document_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='record.documenttype')), + ], + options={ + 'verbose_name': 'Document', + 'verbose_name_plural': 'Documents', + 'db_table': 'document', + 'ordering': ['created_at'], + }, + ), + ] diff --git a/api/record/migrations/0002_fuente_document_fuente.py b/api/record/migrations/0002_fuente_document_fuente.py new file mode 100644 index 0000000..468496d --- /dev/null +++ b/api/record/migrations/0002_fuente_document_fuente.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.3 on 2025-08-12 14:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('record', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Fuente', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre', models.CharField(max_length=100, unique=True)), + ('descripcion', models.TextField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Fuente', + 'verbose_name_plural': 'Fuentes', + 'db_table': 'fuente', + 'ordering': ['nombre'], + }, + ), + migrations.AddField( + model_name='document', + name='fuente', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='record.fuente'), + ), + ] diff --git a/api/record/migrations/__init__.py b/api/record/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/record/models.py b/api/record/models.py new file mode 100644 index 0000000..8d7e047 --- /dev/null +++ b/api/record/models.py @@ -0,0 +1,97 @@ +from django.db import models +import uuid + +from api.organization.models import UsoAlmacenamiento + +# Create your models here. + +class Document(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='documents') + pedimento = models.ForeignKey('customs.Pedimento', on_delete=models.CASCADE, related_name='documents') + archivo = models.FileField(upload_to='documents/', max_length=400) + document_type = models.ForeignKey('DocumentType', on_delete=models.CASCADE, related_name='documents', blank=True, null=True) + extension = models.CharField(max_length=60, blank=True, null=True) + size = models.PositiveIntegerField() + fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + is_new = self._state.adding + + # Usar get_or_create en lugar de get para manejar el caso cuando no existe + uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create( + organizacion=self.organizacion, + defaults={'espacio_utilizado': 0} + ) + + almacenamiento_licencia_bytes = self.organizacion.licencia.almacenamiento * 1024 ** 3 + + if is_new: + if uso_almacenamiento.espacio_utilizado + self.size > almacenamiento_licencia_bytes: + raise ValueError("La organización no tiene suficiente espacio de almacenamiento disponible") + + super().save(*args, **kwargs) + + uso_almacenamiento.espacio_utilizado += self.size + uso_almacenamiento.save() + else: + old_file = Document.objects.get(pk=self.pk) + if old_file.size != self.size: + diferencia = self.size - old_file.size + if uso_almacenamiento.espacio_utilizado + diferencia > almacenamiento_licencia_bytes: + raise ValueError("No hay suficiente espacio para la actualización") + + uso_almacenamiento.espacio_utilizado += diferencia + uso_almacenamiento.save() + + super().save(*args, **kwargs) + def delete(self, *args, **kwargs): + # Usar get_or_create aquí también por si acaso + uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create( + organizacion=self.organizacion, + defaults={'espacio_utilizado': 0} + ) + + uso_almacenamiento.espacio_utilizado -= self.size + uso_almacenamiento.save() + + super().delete(*args, **kwargs) + + def __str__(self): + return f"{self.archivo.name}" + + class Meta: + verbose_name = "Document" + verbose_name_plural = "Documents" + db_table = 'document' + ordering = ['created_at'] + +class DocumentType(models.Model): + nombre = models.CharField(max_length=100, unique=True) + descripcion = models.TextField(blank=True, null=True) + + def __str__(self): + return self.nombre + + class Meta: + verbose_name = "Tipo de Documento" + verbose_name_plural = "Tipos de Documento" + db_table = 'document_type' + ordering = ['nombre'] + +class Fuente(models.Model): + nombre = models.CharField(max_length=100, unique=True) + descripcion = models.TextField(blank=True, null=True) + + def __str__(self): + return self.nombre + + class Meta: + verbose_name = "Fuente" + verbose_name_plural = "Fuentes" + db_table = 'fuente' + ordering = ['nombre'] \ No newline at end of file diff --git a/api/record/serializers.py b/api/record/serializers.py new file mode 100644 index 0000000..3a67892 --- /dev/null +++ b/api/record/serializers.py @@ -0,0 +1,39 @@ +from rest_framework import serializers + +from .models import Document, Fuente, DocumentType + + + +from api.customs.models import Pedimento + +class DocumentSerializer(serializers.ModelSerializer): + pedimento_numero = serializers.SerializerMethodField(read_only=True) + pedimento = serializers.PrimaryKeyRelatedField(queryset=Pedimento.objects.all()) + + class Meta: + model = Document + fields = ('id', 'organizacion', 'pedimento', 'pedimento_numero', 'archivo', 'document_type', 'size', 'extension', 'fuente','created_at', 'updated_at') + read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero') + + def get_pedimento_numero(self, obj): + if obj.pedimento: + return obj.pedimento.pedimento_app + return None + + def validate_archivo(self, value): + """Validar que se proporcione un archivo""" + if not value: + raise serializers.ValidationError("Se requiere un archivo para subir") + return value + +class FuenteSerializer(serializers.ModelSerializer): + class Meta: + model = Fuente + fields = ('id', 'nombre', 'descripcion') + read_only_fields = ('id','nombre', 'descripcion') + +class DocumentTypeSerializer(serializers.ModelSerializer): + class Meta: + model = DocumentType + fields = ('id', 'nombre', 'descripcion') + read_only_fields = ('id', 'nombre', 'descripcion') \ No newline at end of file diff --git a/api/record/tests.py b/api/record/tests.py new file mode 100644 index 0000000..0c920b6 --- /dev/null +++ b/api/record/tests.py @@ -0,0 +1,97 @@ + +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.core.files.uploadedfile import SimpleUploadedFile +from api.organization.models import Organizacion, UsoAlmacenamiento +from api.cuser.models import CustomUser +from api.customs.models import Pedimento +from .models import Document +import io + +class DocumentViewSetTests(APITestCase): + def setUp(self): + self.org = Organizacion.objects.create(nombre="OrgTest", is_active=True, is_verified=True) + self.pedimento = Pedimento.objects.create(organizacion=self.org, numero="123456") + self.admin = CustomUser.objects.create_user(username="admin", password="adminpass", organizacion=self.org) + self.admin.groups.create(name="admin") + self.superuser = CustomUser.objects.create_superuser(username="superuser", password="superpass") + self.client = APIClient() + + def test_list_documents_only_own_org(self): + doc1 = Document.objects.create(organizacion=self.org, pedimento=self.pedimento, archivo="documents/test1.pdf", size=100, extension="pdf") + org2 = Organizacion.objects.create(nombre="OrgTest2", is_active=True, is_verified=True) + ped2 = Pedimento.objects.create(organizacion=org2, numero="654321") + Document.objects.create(organizacion=org2, pedimento=ped2, archivo="documents/test2.pdf", size=200, extension="pdf") + self.client.force_authenticate(user=self.admin) + url = reverse('Document-list') + response = self.client.get(url) + ids = [d['id'] for d in response.data] + self.assertIn(str(doc1.id), ids) + self.assertEqual(len(ids), 1) + + def test_create_document_success(self): + self.client.force_authenticate(user=self.admin) + file_content = b"dummy pdf content" + archivo = SimpleUploadedFile("test.pdf", file_content, content_type="application/pdf") + url = reverse('Document-list') + data = { + "pedimento": str(self.pedimento.id), + "archivo": archivo, + "size": len(file_content), + "extension": "pdf" + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + doc = Document.objects.get(id=response.data['id']) + self.assertEqual(doc.organizacion, self.org) + + def test_update_document_size(self): + doc = Document.objects.create(organizacion=self.org, pedimento=self.pedimento, archivo="documents/test1.pdf", size=100, extension="pdf") + self.client.force_authenticate(user=self.admin) + url = reverse('Document-detail', args=[doc.id]) + file_content = b"new content" + archivo = SimpleUploadedFile("test2.pdf", file_content, content_type="application/pdf") + data = { + "archivo": archivo, + "size": len(file_content), + "extension": "pdf" + } + response = self.client.patch(url, data, format='multipart') + self.assertEqual(response.status_code, status.HTTP_200_OK) + doc.refresh_from_db() + self.assertEqual(doc.size, len(file_content)) + + def test_delete_document_frees_storage(self): + doc = Document.objects.create(organizacion=self.org, pedimento=self.pedimento, archivo="documents/test1.pdf", size=100, extension="pdf") + UsoAlmacenamiento.objects.create(organizacion=self.org, espacio_utilizado=100) + self.client.force_authenticate(user=self.admin) + url = reverse('Document-detail', args=[doc.id]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + uso = UsoAlmacenamiento.objects.get(organizacion=self.org) + self.assertEqual(uso.espacio_utilizado, 0) + + def test_permission_denied_for_other_org(self): + org2 = Organizacion.objects.create(nombre="OrgTest2", is_active=True, is_verified=True) + ped2 = Pedimento.objects.create(organizacion=org2, numero="654321") + doc2 = Document.objects.create(organizacion=org2, pedimento=ped2, archivo="documents/test2.pdf", size=200, extension="pdf") + self.client.force_authenticate(user=self.admin) + url = reverse('Document-detail', args=[doc2.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_superuser_can_access_all(self): + org2 = Organizacion.objects.create(nombre="OrgTest2", is_active=True, is_verified=True) + ped2 = Pedimento.objects.create(organizacion=org2, numero="654321") + doc2 = Document.objects.create(organizacion=org2, pedimento=ped2, archivo="documents/test2.pdf", size=200, extension="pdf") + self.client.force_authenticate(user=self.superuser) + url = reverse('Document-detail', args=[doc2.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_protected_download_requires_auth(self): + doc = Document.objects.create(organizacion=self.org, pedimento=self.pedimento, archivo="documents/test1.pdf", size=100, extension="pdf") + url = reverse('descargar-documento', args=[doc.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/api/record/urls.py b/api/record/urls.py new file mode 100644 index 0000000..339e8bd --- /dev/null +++ b/api/record/urls.py @@ -0,0 +1,27 @@ +# This file defines the URL patterns for the customs app in a Django project. +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +# import necessary viewsets +# from .views import YourViewSet # Import your viewsets here +from .views import DocumentViewSet, ProtectedDocumentDownloadView, BulkDownloadZipView, GetFuenteView, DocumentTypeView +# Create a router and register your viewsets with it + +router = DefaultRouter() + +# Register your viewsets with the router here +# Example: +# from .views import MyViewSet +# router.register(r'myviewset', MyViewSet, basename='myviewset') +router.register(r'documents', DocumentViewSet, basename='Document') + + +# No registres ProtectedDocumentDownloadView en el router, solo como path individual + +urlpatterns = [ + path('documents/bulk-download/', BulkDownloadZipView.as_view(), name='bulk-download-documents'), + path('documents/descargar//', ProtectedDocumentDownloadView.as_view(), name='descargar-documento'), + path('fuente/', GetFuenteView.as_view(), name='get-fuente'), + path('document-type/', DocumentTypeView.as_view(), name='document-type-list-create'), + path('', include(router.urls)), +] \ No newline at end of file diff --git a/api/record/views.py b/api/record/views.py new file mode 100644 index 0000000..c11432a --- /dev/null +++ b/api/record/views.py @@ -0,0 +1,266 @@ +from django.shortcuts import render +from django.http import FileResponse, Http404 +from django.db import transaction + +from rest_framework.pagination import PageNumberPagination +from rest_framework.views import APIView +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework import status +from rest_framework.exceptions import ValidationError + +from .serializers import DocumentSerializer, FuenteSerializer, DocumentTypeSerializer +from .models import Document, Fuente, DocumentType +from api.organization.models import UsoAlmacenamiento +from io import BytesIO +import zipfile +from django.utils.text import slugify +from django.http import HttpResponse +from rest_framework.decorators import action +from datetime import timedelta +from django.utils import timezone + +from core.permissions import ( + IsSameOrganization, + IsSameOrganizationDeveloper, + IsSameOrganizationAndAdmin, + IsSuperUser +) + +import logging +logger = logging.getLogger(__name__) + +from mixins.filtrado_organizacion import DocumentosFiltradosMixin + +class CustomPagination(PageNumberPagination): + + """ + Paginación personalizada con parámetros flexibles + - Si no se especifica page_size, devuelve todos los resultados (sin paginación) + - Si se especifica page_size, usa paginación normal + """ + page_size = None # Por defecto 10000 por página + page_size_query_param = 'page_size' + max_page_size = 10000 # Límite máximo de seguridad + page_query_param = 'page' + + # Usar la paginación estándar de DRF, pero con page_size=10000 por defecto y máximo 10000 + +# Create your views here. +class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): + """ + ViewSet for Document model. + """ + permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] + model = Document + + pagination_class = CustomPagination + serializer_class = DocumentSerializer + # Habilitar filtro por pedimento (UUID) y pedimento_numero (campo pedimento del modelo relacionado) + filterset_fields = ['extension', 'size', 'document_type', 'pedimento', 'pedimento__pedimento'] + + # Puedes filtrar por pedimento usando: /api/record/documents/?pedimento= o /api/record/documents/?pedimento__pedimento= + # Ejemplo: /api/record/documents/?pedimento_numero=12345678 + my_tags = ['Documents'] + + def get_queryset(self): + queryset = self.get_queryset_filtrado_por_organizacion() + pedimento_numero = self.request.query_params.get('pedimento_numero') + if pedimento_numero: + queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero) + + return queryset + + @transaction.atomic + def perform_create(self, serializer): + user = self.request.user + if not user.is_authenticated or not hasattr(user, 'organizacion'): + raise ValidationError({"error": "Usuario no autenticado o sin organización"}) + + archivo = self.request.FILES.get('archivo') + if not archivo: + raise ValidationError({"archivo": "Se requiere un archivo para subir"}) + + # Permitir que el superusuario especifique la organización + organizacion = user.organizacion + + if self.request.user.is_superuser: + organizacion = serializer.validated_data.get('organizacion', organizacion) + + uso = UsoAlmacenamiento.objects.select_for_update().get_or_create( + organizacion=organizacion, + defaults={'espacio_utilizado': 0} + )[0] + + # Calcular límites + max_almacenamiento_bytes = organizacion.licencia.almacenamiento * 1024 ** 3 + nuevo_espacio_utilizado = uso.espacio_utilizado + archivo.size + + # Validación estricta con raise ValidationError + if nuevo_espacio_utilizado > max_almacenamiento_bytes: + espacio_faltante = nuevo_espacio_utilizado - max_almacenamiento_bytes + raise ValidationError({ + "error": "Espacio de almacenamiento insuficiente", + "detalle": { + "espacio_faltante_gb": round(espacio_faltante / (1024 ** 3), 2), + "espacio_utilizado_gb": round(uso.espacio_utilizado / (1024 ** 3), 2), + "limite_gb": organizacion.licencia.almacenamiento, + "archivo_gb": round(archivo.size / (1024 ** 3), 4) + }, + "codigo": "storage_limit_exceeded" + }, code=status.HTTP_400_BAD_REQUEST) + + # Guardar documento y actualizar espacio atómicamente + documento = serializer.save( + organizacion=organizacion, + size=archivo.size, + extension=archivo.name.split('.')[-1].lower() + ) + + uso.espacio_utilizado = nuevo_espacio_utilizado + uso.save() + + @transaction.atomic + def perform_update(self, serializer): + instance = self.get_object() + new_file = self.request.FILES.get('archivo') + + if new_file: + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValidationError({"error": "Usuario no autenticado o sin organización"}) + + organizacion = self.request.user.organizacion + uso = UsoAlmacenamiento.objects.select_for_update().get(organizacion=organizacion) + + diferencia = new_file.size - instance.size + max_almacenamiento_bytes = organizacion.licencia.almacenamiento * 1024 ** 3 + nuevo_espacio_utilizado = uso.espacio_utilizado + diferencia + + if nuevo_espacio_utilizado > max_almacenamiento_bytes: + espacio_faltante = nuevo_espacio_utilizado - max_almacenamiento_bytes + raise ValidationError({ + "error": "Espacio insuficiente para actualizar el archivo", + "detalle": { + "espacio_faltante_bytes": espacio_faltante, + "tamaño_nuevo_archivo": new_file.size, + "tamaño_anterior_archivo": instance.size + }, + "codigo": "update_storage_limit_exceeded" + }, code=status.HTTP_400_BAD_REQUEST) + + # Actualizar documento y espacio + serializer.save(size=new_file.size) + uso.espacio_utilizado = nuevo_espacio_utilizado + uso.save() + else: + serializer.save() + + def perform_destroy(self, instance): + # Restar el espacio al eliminar + uso = UsoAlmacenamiento.objects.get(organizacion=instance.organizacion) + uso.espacio_utilizado -= instance.size + uso.save() + instance.delete() + +class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin): + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + serializer_class = DocumentSerializer + model = Document + my_tags = ['Documents'] + + def get_queryset(self): + return self.get_queryset_filtrado_por_organizacion() + + def get(self, request, pk): + if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + raise Http404("Usuario no autenticado") + + + try: + doc = Document.objects.get(pk=pk) + except Document.DoesNotExist: + raise Http404("Documento no encontrado") + + # Verifica que el usuario pertenece a la organización del documento + + if self.request.user.is_superuser: + return FileResponse(doc.archivo.open('rb')) + + if doc.organizacion != request.user.organizacion: + raise Http404("No autorizado") + + return FileResponse(doc.archivo.open('rb')) + +class BulkDownloadZipView(APIView): + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + my_tags = ['Documents'] + + def post(self, request): + + if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + return Response({"error": "Usuario no autenticado o sin organización"}, status=401) + + pks = request.data.get('document_ids', []) + pedimento_nombre = request.data.get('pedimento_nombre', 'documentos') + if not isinstance(pks, list) or not pks: + return Response({"error": "Debe proporcionar una lista de IDs de documentos en 'document_ids'."}, status=400) + + if self.request.user.is_superuser: + docs = Document.objects.filter(pk__in=pks) + else: + docs = Document.objects.filter(pk__in=pks, organizacion=request.user.organizacion) + + if docs.count() != len(pks): + return Response({"error": "Uno o más documentos no existen o no pertenecen a su organización."}, status=404) + + buffer = BytesIO() + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for doc in docs: + # Usar solo el nombre del archivo sin descripcion + file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0]) + ext = doc.archivo.name.split('.')[-1] + zip_name = f"{file_name}.{ext}" + doc.archivo.open('rb') + zip_file.writestr(zip_name, doc.archivo.read()) + doc.archivo.close() + + buffer.seek(0) + safe_name = slugify(pedimento_nombre) + response = HttpResponse(buffer, content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename={safe_name or "documentos"}.zip' + + return response + +class GetFuenteView(APIView): + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + serializer_class = FuenteSerializer + my_tags = ['Fuente Documentos'] + + def get_queryset(self): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return Fuente.objects.none() + return Fuente.objects.all() + + def get(self, request): + queryset = self.get_queryset() + serializer = self.serializer_class(queryset, many=True) + return Response(serializer.data, status=200) + +class DocumentTypeView(APIView): + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + serializer_class = DocumentTypeSerializer + my_tags = ['Tipo de Documentos'] + + def get_queryset(self): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return DocumentType.objects.none() + return DocumentType.objects.all() + + def get(self, request): + queryset = self.get_queryset() + if not queryset.exists(): + return Response({"detail": "No hay tipos de documento disponibles."}, status=404) + serializer = self.serializer_class(queryset, many=True) + return Response(serializer.data, status=200) \ No newline at end of file diff --git a/api/reports/__init__.py b/api/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/reports/admin.py b/api/reports/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/api/reports/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/api/reports/apps.py b/api/reports/apps.py new file mode 100644 index 0000000..bfa9b73 --- /dev/null +++ b/api/reports/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.reports' diff --git a/api/reports/models.py b/api/reports/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/api/reports/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/api/reports/serializers.py b/api/reports/serializers.py new file mode 100644 index 0000000..2fe378e --- /dev/null +++ b/api/reports/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + +class ExportModelSerializer(serializers.Serializer): + model = serializers.CharField() + fields = serializers.ListField(child=serializers.CharField()) + filters = serializers.DictField(required=False) + type = serializers.CharField(default='csv') diff --git a/api/reports/tests.py b/api/reports/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/api/reports/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api/reports/urls.py b/api/reports/urls.py new file mode 100644 index 0000000..0325c13 --- /dev/null +++ b/api/reports/urls.py @@ -0,0 +1,6 @@ +from django.urls import path, include +from .views import ExportModelView + +urlpatterns = [ + path('exportmodel/', ExportModelView.as_view(), name='export-model'), +] \ No newline at end of file diff --git a/api/reports/views.py b/api/reports/views.py new file mode 100644 index 0000000..3b7784e --- /dev/null +++ b/api/reports/views.py @@ -0,0 +1,93 @@ +import csv +import io + +from rest_framework.views import APIView +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from .serializers import ExportModelSerializer +from rest_framework.response import Response +from django.http import HttpResponse +import openpyxl +from django.apps import apps +from rest_framework import status +from django.shortcuts import render +from rest_framework import viewsets + +from .serializers import ExportModelSerializer + + +def export_model_to_csv(request, model_name, fields, filters=None): + model = apps.get_model('datastage', model_name) + queryset = model.objects.filter(**(filters or {})).values(*fields) + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="{model_name}.csv"' + writer = csv.DictWriter(response, fieldnames=fields) + writer.writeheader() + for row in queryset: + writer.writerow(row) + return response + +def export_model_to_excel(request, model_name, fields, filters=None): + model = apps.get_model('datastage', model_name) + queryset = model.objects.filter(**(filters or {})).values(*fields) + wb = openpyxl.Workbook() + ws = wb.active + ws.append(fields) + for row in queryset: + ws.append([row[field] for field in fields]) + output = io.BytesIO() + wb.save(output) + output.seek(0) + response = HttpResponse(output.read(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + response['Content-Disposition'] = f'attachment; filename="{model_name}.xlsx"' + return response + +# Create your views here. +from rest_framework.views import APIView + +class ExportModelView(APIView): + my_tags = ['Reportes'] + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter('model', openapi.IN_QUERY, description="Nombre del modelo (ejemplo: Registro500)", type=openapi.TYPE_STRING, required=True) + ], + responses={200: openapi.Response('Campos disponibles', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'fields': openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_STRING)) + } + ))} + ) + def get(self, request, *args, **kwargs): + """ + Devuelve los campos disponibles para el modelo solicitado. + Ejemplo: /api/reports/exportmodel/?model=Registro500 + """ + model_name = request.query_params.get('model') + if not model_name: + return Response({'error': 'model is required'}, status=status.HTTP_400_BAD_REQUEST) + try: + model = apps.get_model('datastage', model_name) + except LookupError: + return Response({'error': f'Model {model_name} not found'}, status=status.HTTP_404_NOT_FOUND) + fields = [f.name for f in model._meta.fields] + return Response({'fields': fields}) + + @swagger_auto_schema( + request_body=ExportModelSerializer, + responses={200: 'Archivo generado (Excel o CSV)'} + ) + def post(self, request, *args, **kwargs): + model_name = request.data.get('model') + fields = request.data.get('fields') + filters = request.data.get('filters', {}) + export_type = request.data.get('type', 'csv') + + if not model_name or not fields: + return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST) + + if export_type == 'excel': + return export_model_to_excel(request, model_name, fields, filters) + else: + return export_model_to_csv(request, model_name, fields, filters) \ No newline at end of file diff --git a/api/vucem/__init__.py b/api/vucem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/vucem/admin.py b/api/vucem/admin.py new file mode 100644 index 0000000..90bfabb --- /dev/null +++ b/api/vucem/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from .models import Vucem, CredencialesImportador + +# Register your models here. + +class VucemAdmin(admin.ModelAdmin): + list_display = ('id', 'organizacion', 'usuario', 'patente', 'is_importador', 'is_active', 'created_at', 'updated_at') + search_fields = ('usuario', 'patente') + list_filter = ('is_importador', 'acusecove', 'acuseedocument', 'is_active') + ordering = ('-created_at',) + + +class CredencialesImportadorAdmin(admin.ModelAdmin): + list_display = ('id', 'organizacion', 'vucem', 'rfc', 'created_at', 'updated_at') + search_fields = ('rfc',) + list_filter = ('organizacion',) + ordering = ('-created_at',) + +admin.site.register(Vucem, VucemAdmin) +admin.site.register(CredencialesImportador, CredencialesImportadorAdmin) \ No newline at end of file diff --git a/api/vucem/apps.py b/api/vucem/apps.py new file mode 100644 index 0000000..c78e1aa --- /dev/null +++ b/api/vucem/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class VucemConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.vucem' \ No newline at end of file diff --git a/api/vucem/migrations/0001_initial.py b/api/vucem/migrations/0001_initial.py new file mode 100644 index 0000000..bc79228 --- /dev/null +++ b/api/vucem/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.3 on 2025-07-14 16:14 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organization', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Vucem', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('usuario', models.CharField(help_text='Usuario de VUCEM', max_length=100, unique=True)), + ('password', models.CharField(help_text='Contraseña de VUCEM', max_length=100)), + ('patente', models.CharField(help_text='Patente de VUCEM', max_length=100, unique=True)), + ('is_importador', models.BooleanField(default=False, help_text='Indica si es importador')), + ('acusecove', models.BooleanField(default=False, help_text='Indica si generara acusecove')), + ('acuseedocument', models.BooleanField(default=False, help_text='Indica si generara acusee edocumento')), + ('is_active', models.BooleanField(default=True, help_text='Indica si el registro está activo')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Fecha de creación del registro')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Fecha de última actualización del registro')), + ('created_by', models.ForeignKey(help_text='Usuario que creó el VUCEM', on_delete=django.db.models.deletion.CASCADE, related_name='vucems_created', to=settings.AUTH_USER_MODEL)), + ('organizacion', models.ForeignKey(help_text='Organización a la que pertenece el VUCEM', on_delete=django.db.models.deletion.CASCADE, related_name='vucems', to='organization.organizacion')), + ('updated_by', models.ForeignKey(blank=True, help_text='Usuario que actualizó el VUCEM', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vucems_updated', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'VUCEM', + 'verbose_name_plural': 'VUCEMs', + 'db_table': 'vucem', + }, + ), + ] diff --git a/api/vucem/migrations/0002_vucem_user.py b/api/vucem/migrations/0002_vucem_user.py new file mode 100644 index 0000000..decfe28 --- /dev/null +++ b/api/vucem/migrations/0002_vucem_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.3 on 2025-07-14 17:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vucem', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='vucem', + name='user', + field=models.ForeignKey(blank=True, help_text='Usuario que creó el VUCEM', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vucems', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/api/vucem/migrations/0003_remove_vucem_user.py b/api/vucem/migrations/0003_remove_vucem_user.py new file mode 100644 index 0000000..bc95f72 --- /dev/null +++ b/api/vucem/migrations/0003_remove_vucem_user.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-07-14 17:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vucem', '0002_vucem_user'), + ] + + operations = [ + migrations.RemoveField( + model_name='vucem', + name='user', + ), + ] diff --git a/api/vucem/migrations/0004_usuarioimportador.py b/api/vucem/migrations/0004_usuarioimportador.py new file mode 100644 index 0000000..a679bb3 --- /dev/null +++ b/api/vucem/migrations/0004_usuarioimportador.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.3 on 2025-07-14 17:41 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vucem', '0003_remove_vucem_user'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UsuarioImportador', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('rfc', models.CharField(help_text='RFC del usuario importador', max_length=13, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Fecha de creación del registro')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Fecha de última actualización del registro')), + ('user', models.ForeignKey(help_text='Usuario de la plataforma asociado al importador', on_delete=django.db.models.deletion.CASCADE, related_name='usuarios_importadores', to=settings.AUTH_USER_MODEL)), + ('vucem', models.ForeignKey(help_text='VUCEM asociado al usuario importador', on_delete=django.db.models.deletion.CASCADE, related_name='usuarios_importadores', to='vucem.vucem')), + ], + options={ + 'verbose_name': 'Usuario Importador', + 'verbose_name_plural': 'Usuarios Importadores', + 'db_table': 'usuario_importador', + }, + ), + ] diff --git a/api/vucem/migrations/0005_usuarioimportador_organizacion.py b/api/vucem/migrations/0005_usuarioimportador_organizacion.py new file mode 100644 index 0000000..6974225 --- /dev/null +++ b/api/vucem/migrations/0005_usuarioimportador_organizacion.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.3 on 2025-07-14 17:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organization', '0002_remove_organizacion_membretado_and_more'), + ('vucem', '0004_usuarioimportador'), + ] + + operations = [ + migrations.AddField( + model_name='usuarioimportador', + name='organizacion', + field=models.ForeignKey(blank=True, help_text='Organización a la que pertenece el usuario importador', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='usuarios_importadores', to='organization.organizacion'), + ), + ] diff --git a/api/vucem/migrations/0006_vucem_cer_vucem_key.py b/api/vucem/migrations/0006_vucem_cer_vucem_key.py new file mode 100644 index 0000000..8c0ea80 --- /dev/null +++ b/api/vucem/migrations/0006_vucem_cer_vucem_key.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-07-23 22:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vucem', '0005_usuarioimportador_organizacion'), + ] + + operations = [ + migrations.AddField( + model_name='vucem', + name='cer', + field=models.FileField(default='', help_text='Certificado de VUCEM', upload_to='vucem_certs/'), + preserve_default=False, + ), + migrations.AddField( + model_name='vucem', + name='key', + field=models.FileField(default='', help_text='Llave privada de VUCEM', upload_to='vucem_keys/'), + preserve_default=False, + ), + ] diff --git a/api/vucem/migrations/0007_vucem_efirma.py b/api/vucem/migrations/0007_vucem_efirma.py new file mode 100644 index 0000000..50a0d5f --- /dev/null +++ b/api/vucem/migrations/0007_vucem_efirma.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.3 on 2025-07-31 16:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vucem', '0006_vucem_cer_vucem_key'), + ] + + operations = [ + migrations.AddField( + model_name='vucem', + name='efirma', + field=models.CharField(default='', help_text='E-Firma de VUCEM', max_length=100), + preserve_default=False, + ), + ] diff --git a/api/vucem/migrations/0008_alter_vucem_efirma.py b/api/vucem/migrations/0008_alter_vucem_efirma.py new file mode 100644 index 0000000..930a4c8 --- /dev/null +++ b/api/vucem/migrations/0008_alter_vucem_efirma.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.3 on 2025-07-31 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vucem', '0007_vucem_efirma'), + ] + + operations = [ + migrations.AlterField( + model_name='vucem', + name='efirma', + field=models.CharField(blank=True, help_text='E-Firma de VUCEM', max_length=100, null=True), + ), + ] diff --git a/api/vucem/migrations/0009_remove_usuarioimportador_user.py b/api/vucem/migrations/0009_remove_usuarioimportador_user.py new file mode 100644 index 0000000..7b21ebc --- /dev/null +++ b/api/vucem/migrations/0009_remove_usuarioimportador_user.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-08-12 14:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vucem', '0008_alter_vucem_efirma'), + ] + + operations = [ + migrations.RemoveField( + model_name='usuarioimportador', + name='user', + ), + ] diff --git a/api/vucem/migrations/0010_rename_usuarioimportador_credencialesimportador_and_more.py b/api/vucem/migrations/0010_rename_usuarioimportador_credencialesimportador_and_more.py new file mode 100644 index 0000000..f2c2253 --- /dev/null +++ b/api/vucem/migrations/0010_rename_usuarioimportador_credencialesimportador_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.3 on 2025-08-12 18:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('organization', '0002_remove_organizacion_membretado_and_more'), + ('vucem', '0009_remove_usuarioimportador_user'), + ] + + operations = [ + migrations.RenameModel( + old_name='UsuarioImportador', + new_name='CredencialesImportador', + ), + migrations.AlterModelOptions( + name='credencialesimportador', + options={'verbose_name': 'Credencial Importador', 'verbose_name_plural': 'Credenciales Importadores'}, + ), + migrations.AlterModelTable( + name='credencialesimportador', + table='credenciales_importadores', + ), + ] diff --git a/api/vucem/migrations/0011_alter_credencialesimportador_rfc.py b/api/vucem/migrations/0011_alter_credencialesimportador_rfc.py new file mode 100644 index 0000000..10d8953 --- /dev/null +++ b/api/vucem/migrations/0011_alter_credencialesimportador_rfc.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.3 on 2025-08-16 16:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0010_alter_pedimento_contribuyente'), + ('vucem', '0010_rename_usuarioimportador_credencialesimportador_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='credencialesimportador', + name='rfc', + field=models.ForeignKey(help_text='RFC del importador asociado al usuario importador', on_delete=django.db.models.deletion.CASCADE, related_name='usuarios_importadores', to='customs.importador'), + ), + ] diff --git a/api/vucem/migrations/__init__.py b/api/vucem/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/vucem/models.py b/api/vucem/models.py new file mode 100644 index 0000000..4a45b10 --- /dev/null +++ b/api/vucem/models.py @@ -0,0 +1,60 @@ +from django.db import models +from api.cuser.models import CustomUser +import uuid + + + +# Create your models here. +class Vucem(models.Model): + + """ + Modelo para almacenar información de VUCEM. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + created_by = models.ForeignKey('cuser.CustomUser', on_delete=models.CASCADE, related_name='vucems_created', help_text="Usuario que creó el VUCEM") + updated_by = models.ForeignKey('cuser.CustomUser', on_delete=models.CASCADE, related_name='vucems_updated', null=True, blank=True, help_text="Usuario que actualizó el VUCEM") + + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='vucems', help_text="Organización a la que pertenece el VUCEM") + usuario = models.CharField(max_length=100, unique=True, help_text="Usuario de VUCEM") + password = models.CharField(max_length=100, help_text="Contraseña de VUCEM") + patente = models.CharField(max_length=100, unique=True, help_text="Patente de VUCEM") + efirma = models.CharField(max_length=100, blank=True, null=True,help_text="E-Firma de VUCEM") + key = models.FileField(upload_to='vucem_keys/', help_text="Llave privada de VUCEM") + cer = models.FileField(upload_to='vucem_certs/', help_text="Certificado de VUCEM") + + is_importador = models.BooleanField(default=False, help_text="Indica si es importador") + acusecove = models.BooleanField(default=False, help_text="Indica si generara acusecove") + acuseedocument = models.BooleanField(default=False, help_text="Indica si generara acusee edocumento") + is_active = models.BooleanField(default=True, help_text="Indica si el registro está activo") + + created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del registro") + updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del registro") + + class Meta: + verbose_name = 'VUCEM' + verbose_name_plural = 'VUCEMs' + db_table = 'vucem' + + def __str__(self): + return self.organizacion.nombre + ' - ' + self.usuario + +class CredencialesImportador(models.Model): + """ + Modelo para almacenar información de usuarios importadores. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='usuarios_importadores', help_text="Organización a la que pertenece el usuario importador", blank=True, null=True) + vucem = models.ForeignKey(Vucem, on_delete=models.CASCADE, related_name='usuarios_importadores', help_text="VUCEM asociado al usuario importador") + rfc = models.ForeignKey('customs.Importador', on_delete=models.CASCADE, related_name='usuarios_importadores', help_text="RFC del importador asociado al usuario importador") + + created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del registro") + updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del registro") + + class Meta: + verbose_name = 'Credencial Importador' + verbose_name_plural = 'Credenciales Importadores' + db_table = 'credenciales_importadores' + + def __str__(self): + return self.organizacion.nombre + ' - ' + str(self.vucem.usuario) diff --git a/api/vucem/serializers.py b/api/vucem/serializers.py new file mode 100644 index 0000000..c2821df --- /dev/null +++ b/api/vucem/serializers.py @@ -0,0 +1,40 @@ + + +from rest_framework import serializers +from .models import Vucem, CredencialesImportador + + + + +class VucemSerializer(serializers.ModelSerializer): + importadores = serializers.SerializerMethodField() + + class Meta: + model = Vucem + fields = '__all__' + read_only_fields = ('created_at', 'updated_at', 'organizacion', 'created_by', 'updated_by') + + def get_importadores(self, obj): + # Importar aquí para evitar importación circular + from api.customs.serializers import ImportadorSerializer + return [ImportadorSerializer(cred.rfc).data for cred in obj.usuarios_importadores.all()] + + +class CredencialesImportadorSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = CredencialesImportador + fields = ('__all__') + read_only_fields = ('updated_at',) + + +class CredencialesImportadorSerializer(serializers.ModelSerializer): + + class Meta: + model = CredencialesImportador + fields = '__all__' + read_only_fields = ('updated_at',) + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation['vucem'] = VucemSerializer(instance.vucem).data + return representation diff --git a/api/vucem/tests.py b/api/vucem/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/api/vucem/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api/vucem/urls.py b/api/vucem/urls.py new file mode 100644 index 0000000..9e4edd3 --- /dev/null +++ b/api/vucem/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import VucemView, CredencialesImportadorViewSet +# Create a router and register your viewsets with it +router = DefaultRouter() + + +# Register your viewsets with the router here + +router.register(r'vucem', VucemView, basename='Vucem') +router.register(r'usuario-importador', CredencialesImportadorViewSet, basename='CredencialesImportador') +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/api/vucem/views.py b/api/vucem/views.py new file mode 100644 index 0000000..e148943 --- /dev/null +++ b/api/vucem/views.py @@ -0,0 +1,182 @@ +from django.shortcuts import render +from rest_framework import viewsets +from rest_framework.pagination import PageNumberPagination +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter, OrderingFilter +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action +from rest_framework.response import Response +from django.http import FileResponse, Http404 + +from .serializers import VucemSerializer, CredencialesImportadorSerializer, CredencialesImportadorSimpleSerializer +from rest_framework import serializers + +# Serializer para update donde key y cer no son requeridos +class VucemUpdateSerializer(VucemSerializer): + key = serializers.FileField(required=False, allow_null=True) + cer = serializers.FileField(required=False, allow_null=True) + + class Meta(VucemSerializer.Meta): + fields = VucemSerializer.Meta.fields +from .models import Vucem, CredencialesImportador +from core.permissions import IsSameOrganizationDeveloper +from rest_framework import mixins + +from core.permissions import ( + IsSameOrganization, + IsSameOrganizationDeveloper, + IsSameOrganizationAndAdmin, + IsSuperUser, + IsSameOrganizationAndInAllowedGroups +) + +class CustomVucemPagination(PageNumberPagination): + """ + Paginación personalizada para VUCEM + """ + page_size = None # Sin paginación por defecto + page_size_query_param = 'page_size' + max_page_size = 1000 + page_query_param = 'page' + + def paginate_queryset(self, queryset, request, view=None): + page_size = request.query_params.get(self.page_size_query_param) + if page_size is None: + return None + return super().paginate_queryset(queryset, request, view) +# Create your views here. + +class VucemView(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated , (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] + + queryset = Vucem.objects.all() + pagination_class = CustomVucemPagination + filterset_fields = ['organizacion', 'patente', 'usuario', 'is_importador', 'acusecove', 'acuseedocument', 'is_active'] + search_fields = ['usuario', 'patente'] + ordering_fields = ['created_at', 'updated_at', 'usuario', 'patente'] + ordering = ['-created_at'] + + def get_serializer_class(self): + if self.action in ['update', 'partial_update']: + return VucemUpdateSerializer + return VucemSerializer + + def get_permissions(self): + if self.action in ['create', 'update', 'partial_update', 'destroy']: + return [IsAuthenticated(), IsSameOrganizationAndInAllowedGroups()] + return super().get_permissions() + + def get_queryset(self): + # Verificar que el usuario esté autenticado y tenga organización + if not self.request.user.is_authenticated: + return self.queryset.none() + + queryset = self.queryset + + if self.request.user.is_superuser: + queryset = queryset.all() + elif not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: + return queryset.none() + elif self.request.user.groups.filter(name='Importador').exists(): + queryset = queryset.filter(organizacion=self.request.user.organizacion, usuario=self.request.user.rfc) + else: + queryset = queryset.filter(organizacion=self.request.user.organizacion) + + # Filtro por importador (RFC) + importador_rfc = self.request.query_params.get('importador') + if importador_rfc: + queryset = queryset.filter(usuarios_importadores__rfc__rfc=importador_rfc).distinct() + + return queryset + + def perform_create(self, serializer): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("El usuario debe estar autenticado y tener una organización asignada.") + if self.request.user.is_superuser: + serializer.save(created_by=self.request.user, updated_by=self.request.user) + return + else: + serializer.save( + organizacion=self.request.user.organizacion, + created_by=self.request.user, + updated_by=self.request.user + ) + return + + def perform_update(self, serializer): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("El usuario debe estar autenticado y tener una organización asignada.") + instance = self.get_object() + if self.request.user.is_superuser: + serializer.save( + created_by=instance.created_by, + updated_by=self.request.user + ) + return + else: + serializer.save( + organizacion=self.request.user.organizacion, + created_by=instance.created_by, + updated_by=self.request.user + ) + return + + @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + def download_cer(self, request, pk=None): + """ + Descarga directa del archivo cer. + """ + vucem = self.get_object() + if not vucem.cer: + return Response({"detail": "No hay archivo cer disponible."}, status=404) + response = FileResponse(vucem.cer.open('rb'), as_attachment=True, filename=vucem.cer.name.split('/')[-1]) + return response + + @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + def download_key(self, request, pk=None): + """ + Descarga directa del archivo key. + """ + vucem = self.get_object() + if not vucem.key: + return Response({"detail": "No hay archivo key disponible."}, status=404) + response = FileResponse(vucem.key.open('rb'), as_attachment=True, filename=vucem.key.name.split('/')[-1]) + return response + + +class CredencialesImportadorViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + queryset = CredencialesImportador.objects.all() + serializer_class = CredencialesImportadorSimpleSerializer + filterset_fields = ['organizacion', 'vucem', 'rfc'] + search_fields = ['rfc'] + ordering_fields = ['created_at', 'updated_at', 'rfc'] + ordering = ['-created_at'] + + my_tags = ['Credenciales por Importador'] + + def get_permissions(self): + if self.action in ['create', 'update', 'partial_update', 'destroy']: + return [IsAuthenticated()] + return super().get_permissions() + + def get_queryset(self): + + if self.request.user.is_superuser: + # Si es superusuario, devolver todos los registros + return self.queryset.all() + + # Verificar que el usuario esté autenticado y tenga organización + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return self.queryset.none() + + queryset = self.queryset.filter(organizacion=self.request.user.organizacion) + + + return queryset + + def perform_create(self, serializer): + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + raise ValueError("El usuario debe estar autenticado y tener una organización asignada.") + serializer.save(organizacion=self.request.user.organizacion) + return \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ed7c431 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..fb276c1 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +app = Celery('config') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..1b44c08 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,352 @@ +# Celery Beat Schedule +from celery.schedules import crontab + + +CELERY_BEAT_SCHEDULE = { + # Ejecutar pedimento completo de 5:00 a 22:00 (cada hora) + 'creacion-servicio-pedimento-completo': { + 'task': 'api.customs.tasks.internal_services.crear_todos_los_servicios', + 'schedule': crontab(minute=0, hour='5-22'), + }, + # Ejecutar pedimento completo de 5:00 a 22:00 (cada hora) + 'ejecutar-pedimentos-completos-dia': { + 'task': 'api.customs.tasks.microservice.ejecutar_pedimento_completo', + 'schedule': crontab(minute=0, hour='5-22'), + }, + # Ejecutar partidas de 5:00 a 22:00 (cada hora) + 'ejecutar-partidas-dia': { + 'task': 'api.customs.tasks.microservice.ejecutar_partidas_pedimento', + 'schedule': crontab(minute=0, hour='5-23'), + }, + # Ejecutar coves de 5:00 a 22:00 (cada hora) + 'ejecutar-coves-dia': { + 'task': 'api.customs.tasks.microservice.ejecutar_coves', + 'schedule': crontab(minute=0, hour='5-23'), + }, + # Ejecutar remesas de 5:00 a 22:00 (cada hora) + 'ejecutar-remesas-dia': { + 'task': 'api.customs.tasks.microservice.ejecutar_remesas', + 'schedule': crontab(minute=0, hour='5-23'), + }, + # Ejecutar acuse coves de 5:00 a 22:00 (cada hora) + 'ejecutar-acuse-coves-dia': { + 'task': 'api.customs.tasks.microservice.ejecutar_acuseCoves', + 'schedule': crontab(minute=0, hour='5-23'), + }, + # Ejecutar acuse de 5:00 a 22:00 (cada hora) + 'ejecutar-acuse-dia': { + 'task': 'api.customs.tasks.microservice.ejecutar_acuse', + 'schedule': crontab(minute=0, hour='5-23'), + }, + # Ejecutar edocs solo de 23:00 a 4:59 (cada hora en ese rango) + 'ejecutar-edocs-noche': { + 'task': 'api.customs.tasks.microservice.ejecutar_edocs', + 'schedule': crontab(minute=42, hour='23,0,1,2,3,4'), + }, + +} +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 5.2.3. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" +import os +import ssl +import smtplib + +from pathlib import Path +from corsheaders.defaults import default_headers +from datetime import timedelta + +# --- SOLO PARA DESARROLLO: Desactivar verificación de certificados SSL en SMTP --- +smtplib.SMTP_SSL.default_context = ssl._create_unverified_context + +from dotenv import load_dotenv +import re + +# Cargar variables de entorno desde un archivo .env +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DEBUG', 'True') == 'True' + +ALLOWED_HOSTS = [ + 'localhost' + ,'host.docker.internal' + ,'192.168.1.195' + ,'127.0.0.1' + ,'74.208.78.59' + ,'api.efc-aduanasoft.com' + ,'backend' + ,'backend:8000' + ,'0.0.0.0' + ,'192.168.1.79' +] + +SITE_URL = os.getenv('SITE_URL') +SERVICE_API_URL = os.getenv('SERVICE_API_URL') + +# Application definition +BASE_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +THIRD_APPS = [ + 'rest_framework', + 'rest_framework.authtoken', + 'rest_framework_simplejwt', + 'drf_yasg', + 'corsheaders', + 'django_filters', + 'rest_framework_simplejwt.token_blacklist', +] + +OWN_APPS = [ + 'api.customs', + 'api.record', + 'api.organization', + 'api.licence', + 'api.cuser', + 'api.datastage', + 'api.vucem', + 'api.logger', + 'api.notificaciones', + 'api.reports', + 'api.cards', +] + +INSTALLED_APPS = BASE_APPS + THIRD_APPS + OWN_APPS +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', # Debe ir antes de CommonMiddleware + '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', + 'api.logger.middleware.RequestLoggingMiddleware', + 'api.logger.middleware.ErrorLoggingMiddleware', +] + +# Crear directorio de logs +LOGS_DIR = BASE_DIR / 'logs' +LOGS_DIR.mkdir(exist_ok=True) + +# Configuración de logging unificada +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + 'celery': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + }, +} + +# Configuración para desarrollo y producción +if DEBUG: + CORS_ALLOW_ALL_ORIGINS = True + CORS_ALLOW_CREDENTIALS = True + SESSION_COOKIE_SECURE = False + CSRF_COOKIE_SECURE = False + USE_X_FORWARDED_HOST = False +else: + CORS_ALLOW_ALL_ORIGINS = False + CORS_ALLOW_CREDENTIALS = False + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS').split(',') + CSRF_COOKIE_SECURE = True + SESSION_COOKIE_SECURE = True + USE_X_FORWARDED_HOST = True + +CORS_ALLOW_HEADERS = list(default_headers) + [ + 'access-control-allow-origin', + 'access-control-allow-credentials', +] + +# # JWT Authentication settings +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.TokenAuthentication', # Añade esta línea + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter', + ], +} + +# Configuración de Swagger +SWAGGER_SETTINGS = { + "DEFAULT_AUTO_SCHEMA_CLASS": "core.swagger.CustomAutoSchema", + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'SECURITY_DEFINITIONS': { + 'Bearer': { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header', + 'description': 'JWT Authorization header using the Bearer scheme. Example: "Authorization: Bearer {token}"' + } + }, + 'USE_SESSION_AUTH': False, + 'LOGIN_URL': '/api/v1/token/', + 'LOGOUT_URL': '/api/v1/auth/logout/', + 'DOC_EXPANSION': 'None', +} + +# Configuración adicional para ReDoc +REDOC_SETTINGS = { + 'LAZY_RENDERING': False, + 'HIDE_HOSTNAME': False, + 'EXPAND_RESPONSES': 'all', + 'PATH_IN_MIDDLE': True, +} + +CSRF_TRUSTED_ORIGINS = [ + "https://api.efc-aduanasoft.com", + "http://192.168.1.195", + "http://192.168.1.195:8000" +] + +# URL Configuration +ROOT_URLCONF = 'config.urls' + +# Template configuration +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', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +# Database +DATABASES = { + 'default': { + 'ENGINE' : 'django.db.backends.postgresql', + 'NAME' : os.getenv('DB_NAME'), + 'USER' : os.getenv('DB_USER'), + 'PASSWORD' : os.getenv('DB_PASSWORD'), + 'HOST' : os.getenv('DB_HOST'), + 'PORT' : os.getenv('DB_PORT'), + } +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'America/Ojinaga' # Zona horaria de Cd. Juárez, Chihuahua +USE_I18N = True +USE_TZ = True + +# Static files +STATIC_URL = 'static/' +if DEBUG: + STATICFILES_DIRS = [BASE_DIR / 'staticfiles'] +else: + STATICFILES_DIRS = [] +STATIC_ROOT = BASE_DIR / 'static' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +AUTH_USER_MODEL = 'cuser.CustomUser' + +# Configuración SMTP para envío de correos electrónicos +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = os.getenv('EMAIL_HOST') +EMAIL_PORT = int(os.getenv('EMAIL_PORT')) +EMAIL_USE_TLS = True +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER + +# Configuración Celery +CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://redis:6379/0') +CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://redis:6379/0') +CELERY_TIMEZONE = 'America/Mexico_City' + +# Configuración para procesamiento asíncrono nativo de Django +ASGI_APPLICATION = 'config.asgi.application' + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Tokens de acceso cortos por seguridad + 'REFRESH_TOKEN_LIFETIME': timedelta(days=5), # Refresh token de 5 días + 'ROTATE_REFRESH_TOKENS': True, # Rotar refresh tokens para mayor seguridad + 'BLACKLIST_AFTER_ROTATION': True, + 'AUTH_HEADER_TYPES': ('Bearer',), +} + + diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..90aaa80 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,59 @@ +from django.contrib import admin +from django.urls import path, include, re_path +from django.conf import settings +# +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +from django.conf.urls.static import static + +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) + +schema_view = get_schema_view( + openapi.Info( + title="EFC API", + default_version='v1', + description="API para el sistema EFC V2 - Gestión de Expediente electronicos de Comercio Exterior", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="contact@aduanasoft.com.mx"), + license=openapi.License(name="MIT License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), + authentication_classes=[], # Desactivar auth para ver la documentación +# url='https://api.efc-aduanasoft.com/api/v1' +) + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/v1/', include('api.licence.urls')), + + # JWT Authentication + path('api/v1/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('api/v1/user/', include(('api.cuser.urls', 'cuser'), namespace='cuser')), # Custom user app + + #path('api-auth/', include('rest_framework.urls')), + path('api/v1/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + path('api/v1/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + + path('api/v1/customs/', include('api.customs.urls')), + path('api/v1/organization/', include('api.organization.urls')), + path('api/v1/record/', include('api.record.urls')), + path('api/v1/datastage/', include('api.datastage.urls')), + path('api/v1/vucem/', include('api.vucem.urls')), + path('api/v1/logger/', include('api.logger.urls')), # Logger app + path('api/v1/notificaciones/', include('api.notificaciones.urls')), # Notificaciones app + path('api/v1/cards/', include('api.cards.urls')), # Cards app + path('api/v1/reports/', include('api.reports.urls')), # Reports app +] +# En producción, los archivos media son servidos por Nginx +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL + 'profile_pictures/', document_root=settings.MEDIA_ROOT / 'profile_pictures') + urlpatterns += static(settings.MEDIA_URL + 'membretado/', document_root=settings.MEDIA_ROOT / 'profile_pictures') + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..e2fbd58 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/dashboard.py b/core/dashboard.py new file mode 100644 index 0000000..e2b838d --- /dev/null +++ b/core/dashboard.py @@ -0,0 +1,17 @@ +# core/dashboard.py +from jet.dashboard.dashboard import Dashboard +from jet.dashboard.modules import DashboardModule + +class ChartModule(DashboardModule): + title = 'Mi Gráfico' + template = 'admin/dashboard_admin.html' # Este template lo crearás más adelante + collapsible = False + + def init_with_context(self, context): + self.children = [] + +class CustomIndexDashboard(Dashboard): + columns = 2 + + def init_with_context(self, context): + self.children.append(ChartModule()) diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000..9d4e798 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,100 @@ +# permissions.py +from rest_framework import permissions +from api.cuser.models import CustomUser + +class IsSameOrganization(permissions.BasePermission): + """ + Permiso personalizado que solo permite acceder a usuarios de la misma organización + o a administradores/staff. + """ + def has_permission(self, request, view): + # Permite listar/crear solo si el usuario está autenticado + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Permite operaciones sobre un objeto específico solo si: + # - El objeto pertenece a la misma organización (acceso por usuario relacionado) + return (getattr(obj, 'dirigido', None) and obj.dirigido.organizacion == request.user.organizacion) + +class IsSameOrganizationAndAdmin(permissions.BasePermission): + """ + Permiso personalizado que solo permite acceder a usuarios de la misma organización + o a administradores/staff. + """ + def has_permission(self, request, view): + # Permite listar/crear solo si el usuario está autenticado + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Permite operaciones solo si el usuario es admin, Agente Aduanal o user y la organización coincide + allowed_groups = ['admin', 'Agente Aduanal', 'user'] + user_in_group = request.user.groups.filter(name__in=allowed_groups).exists() + if not user_in_group: + return False + if hasattr(obj, 'organizacion'): + return obj.organizacion == request.user.organizacion + return False + +class IsSameOrganizationDeveloper(permissions.BasePermission): + """ + Permiso personalizado que solo permite acceder a usuarios de la misma organización + o a administradores/staff. + """ + def has_permission(self, request, view): + # Permite listar/crear solo si el usuario está autenticado + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Permite operaciones solo si el usuario es developer, Agente Aduanal o user y la organización coincide + allowed_groups = ['developer', 'Agente Aduanal', 'user'] + user_in_group = request.user.groups.filter(name__in=allowed_groups).exists() + if not user_in_group: + return False + if hasattr(obj, 'organizacion'): + return obj.organizacion == request.user.organizacion + return False + +class IsOwnerOrOrgAdmin(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + return ( + obj == request.user or + request.user.is_staff or + request.user.groups.filter(name='admin').exists() + ) + +class IsSuperUser(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + return request.user.is_superuser + +class HasStoragePermission(permissions.BasePermission): + """ + Permiso personalizado que permite el acceso a los usuarios que tienen permisos de almacenamiento. + """ + def has_permission(self, request, view): + # Permite el acceso si el usuario tiene el permiso 'can_access_storage' + return request.user.has_perm('api.cuser.can_access_storage') + + def has_object_permission(self, request, view, obj): + # Permite operaciones sobre un objeto específico si el usuario tiene el permiso + return request.user.has_perm('api.cuser.can_access_storage') + +class IsSameOrganizationAndInAllowedGroups(permissions.BasePermission): + """ + Permite update/delete solo si el usuario está en TODOS los grupos permitidos + y pertenece a la misma organización que el registro, o es superuser. + """ + allowed_groups = ['admin', 'Agente Aduanal', 'user'] + + def has_object_permission(self, request, view, obj): + user = request.user + if not user.is_authenticated: + return False + if user.is_superuser: + return True + if not hasattr(user, 'organizacion') or not user.organizacion: + return False + # Debe tener los tres grupos asignados + for group in self.allowed_groups: + if not user.groups.filter(name=group).exists(): + return False + return obj.organizacion == user.organizacion \ No newline at end of file diff --git a/core/swagger.py b/core/swagger.py new file mode 100644 index 0000000..decce3a --- /dev/null +++ b/core/swagger.py @@ -0,0 +1,8 @@ +from drf_yasg.inspectors import SwaggerAutoSchema + +class CustomAutoSchema(SwaggerAutoSchema): + def get_tags(self, operation_keys=None): + tags = self.overrides.get('tags', None) or getattr(self.view, 'my_tags', []) + if not tags: + tags = [operation_keys[0]] + return tags \ No newline at end of file diff --git a/core/swagger_auth.py b/core/swagger_auth.py new file mode 100644 index 0000000..1888e49 --- /dev/null +++ b/core/swagger_auth.py @@ -0,0 +1,55 @@ +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + + +def jwt_required_swagger_schema(**kwargs): + """ + Decorador para endpoints que requieren autenticación JWT + """ + security_requirement = [{'Bearer': []}] + + # Obtener las respuestas existentes o crear un diccionario vacío + responses = kwargs.get('responses', {}) + + # Agregar respuesta 401 para endpoints autenticados + responses[401] = openapi.Response( + description="No autorizado - Token JWT requerido", + examples={ + 'application/json': { + 'detail': 'Given token not valid for any token type', + 'code': 'token_not_valid', + 'messages': [ + { + 'token_class': 'AccessToken', + 'token_type': 'access', + 'message': 'Token is invalid or expired' + } + ] + } + } + ) + + # Agregar respuesta 403 para problemas de permisos + responses[403] = openapi.Response( + description="Acceso denegado - Permisos insuficientes", + examples={ + 'application/json': { + 'detail': 'No tiene permisos para realizar esta acción.' + } + } + ) + + kwargs['responses'] = responses + kwargs['security'] = security_requirement + + return swagger_auto_schema(**kwargs) + + +# Headers comunes para autenticación +JWT_AUTH_HEADER = openapi.Parameter( + 'Authorization', + openapi.IN_HEADER, + description="JWT Token - Formato: Bearer {token}", + type=openapi.TYPE_STRING, + required=True +) \ No newline at end of file diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..88e9e18 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,310 @@ +from api.organization.models import UsoAlmacenamiento +from dataclasses import dataclass +import xml.etree.ElementTree as ET +from typing import List, Dict + + +def verificar_espacio_disponible(organizacion, tamaño_archivo): + try: + uso = UsoAlmacenamiento.objects.get(organizacion=organizacion) + if uso.espacio_disponible < tamaño_archivo: + raise ValueError("La organización no tiene suficiente espacio de almacenamiento disponible") + return True + except UsoAlmacenamiento.DoesNotExist: + # Si no existe registro, crear uno + UsoAlmacenamiento.objects.create(organizacion=organizacion, espacio_utilizado=0) + return True + + + + +@dataclass +class PedimentoScrapper: # Clase me extrae datos de Pedimento + """ + Clase para manejar la extracción de datos de un XML. + """ + + def _get_numero_operacion(self, root: ET.Element) -> str: + """ + Método para obtener el número de operación del XML. + + Args: + root: Elemento raíz del XML. + + Returns: + Número de operación como string. + """ + numero_operacion = root.find('.//ns2:numeroOperacion', namespaces={'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'}) + return numero_operacion.text if numero_operacion is not None else None + + def _get_pedimento(self, root: ET.Element) -> str: + """ + Método para obtener el pedimento del XML. + + Args: + root: Elemento raíz del XML. + + Returns: + Pedimento como string. + """ + pedimento = root.find('.//ns2:pedimento/ns2:pedimento', namespaces={'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'}) + return pedimento.text if pedimento is not None else None + + def _get_curp_apoderado(self, root: ET.Element) -> str: + """ + Método para obtener el CURP del apoderado del XML. + + Args: + root: Elemento raíz del XML. + + Returns: + CURP del apoderado como string. + """ + curp_apoderado = root.find('.//ns2:curpApoderadomandatario', namespaces={'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'}) + return curp_apoderado.text if curp_apoderado is not None else None + + def _get_agente_aduanal(self, root: ET.Element) -> str: + """ + Método para obtener el RFC del agente aduanal del XML. + + Args: + root: Elemento raíz del XML. + + Returns: + RFC del agente aduanal como string. + """ + agente_aduanal = root.find('.//ns2:rfcAgenteAduanalSocFactura', namespaces={'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'}) + return agente_aduanal.text if agente_aduanal is not None else None + + def _get_partidas(self, root: ET.Element) -> int: + """ + Método para obtener el número máximo de partidas del XML. + + Args: + root: Elemento raíz del XML. + + Returns: + Número máximo de partidas como entero. + """ + partidas_elements = root.findall('.//ns2:partidas', namespaces={'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'}) + partidas_values = [] + for elem in partidas_elements: + try: + if elem.text is not None: + partidas_values.append(int(elem.text)) + except ValueError: + continue + + return max(partidas_values) if partidas_values else None + + def _get_identificadores_ed(self, root: ET.Element) -> list: + """ + Método para obtener todos los identificadores con clave 'ED' del XML. + + Args: + root: Elemento raíz del XML. + + Returns: + Lista de diccionarios con los datos de identificadores ED. + """ + namespaces = { + 'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto', + 'ns': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/comunes' + } + identificadores_ed = [] + + # Buscar todos los elementos identificadores + identificadores_elements = root.findall('.//ns2:identificadores/ns2:identificadores', namespaces) + + for identificador in identificadores_elements: + try: + # Extraer la clave del identificador (está dentro de claveIdentificador con namespace) + clave_elem = identificador.find('ns:claveIdentificador/ns:clave', namespaces) + clave = clave_elem.text if clave_elem is not None else None + + # Solo procesar si la clave es 'ED' + if clave == 'ED': + # Extraer descripción (con namespace) + descripcion_elem = identificador.find('ns:claveIdentificador/ns:descripcion', namespaces) + descripcion = descripcion_elem.text if descripcion_elem is not None else None + + # Extraer complemento1 (con namespace) + complemento1_elem = identificador.find('ns:complemento1', namespaces) + complemento1 = complemento1_elem.text if complemento1_elem is not None else None + + # Agregar a la lista si tenemos los datos básicos + if clave and complemento1: + identificadores_ed.append({ + 'clave': clave, + 'descripcion': descripcion, + 'complemento1': complemento1 + }) + + except Exception as e: + # Log del error pero continuar procesando otros identificadores + print(f"Error procesando identificador: {e}") + continue + + return identificadores_ed + + def _remesas(self, root: ET.Element) -> bool: + """ + Método para verificar si el pedimento tiene remesas. + Busca identificadores con clave 'RC' (REMESAS DE CONSOLIDADO). + + Args: + root: Elemento raíz del XML. + + Returns: + True si encuentra identificadores con clave 'RC', False en caso contrario. + """ + namespaces = { + 'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto', + 'ns': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/comunes' + } + + # Buscar todos los elementos identificadores + identificadores_elements = root.findall('.//ns2:identificadores/ns2:identificadores', namespaces) + + for identificador in identificadores_elements: + try: + # Extraer la clave del identificador + clave_elem = identificador.find('ns:claveIdentificador/ns:clave', namespaces) + clave = clave_elem.text if clave_elem is not None else None + + # Si encontramos una clave 'RC', el pedimento tiene remesas + if clave == 'RC': + return True + + except Exception as e: + # Log del error pero continuar procesando otros identificadores + continue + return False + + def _get_tipo_operacion(self, root: ET.Element) -> str: + """ + Método para obtener el tipo de operación del XML. + + Args: + root: Elemento raíz del XML. + + Returns: + Tipo de operación como string. + """ + tipo_operacion = root.find('.//ns2:tipoOperacion/ns2:clave', namespaces={'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'}) + return tipo_operacion.text if tipo_operacion is not None else None + + def _get_cove(self, root: ET.Element) -> str: + """ + Método para obtener el número de COVE del XML. + + Args: + root: Elemento raíz del XML. + + Returns: + Número de COVE como string. + """ + namespaces = { + 'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto', + 'ns': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/comunes' + } + facturas = root.findall('.//ns2:facturas', namespaces=namespaces) + coves = [] + for factura in facturas: + cove = factura.find('ns2:numero', namespaces) + if cove is not None: + coves.append(cove.text) + else: + print("No se encontró en la factura.") + + return coves if coves else None + + def extract_data(self, xml_content: str) -> dict: + """ + Método para extraer datos específicos del XML. + + Args: + xml_content: Contenido del XML como string. + + Returns: + Diccionario con los datos extraídos. + """ + try: + root = ET.fromstring(xml_content) + + # Extraer datos con manejo de errores individual + data = {} + + data['numero_operacion'] = self._get_numero_operacion(root) + data['pedimento'] = self._get_pedimento(root) + data['curp_apoderado'] = self._get_curp_apoderado(root) + data['agente_aduanal'] = self._get_agente_aduanal(root) + data['numero_partidas'] = self._get_partidas(root) + data['identificadores_ed'] = self._get_identificadores_ed(root) + data['remesas'] = self._remesas(root) + data['tipo_operacion'] = self._get_tipo_operacion(root) + data['coves'] = self._get_cove(root) + + # Verificar que se extrajeron los datos esenciales + if not any([data['numero_operacion'], data['pedimento'], data['curp_apoderado'], data['agente_aduanal'], data['coves']]): + return {} + + return data + + except ET.ParseError as e: + print(f"Error al parsear el XML: {e}") + return {} + except Exception as e: + print(f"Error inesperado al extraer datos del XML: {e}") + return {} + + return extract_xml_data(xml_content) + +class XMLControllerRemesas: + """ + Controlador para scrapear XML de consultar remesas. + Extrae todos los comprobantesVE, junto con remesaAgente y remesaSA. + """ + + namespaces = { + "S": "http://schemas.xmlsoap.org/soap/envelope/", + "ns2": "http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta", + "ns3": "http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarremesas", + } + + def extract_remesas(self, xml_content: str) -> List[Dict[str, str]]: + """ + Extrae todos los comprobanteVE de un XML de remesas. + + Args: + xml_content: Contenido del XML en string. + + Returns: + Lista de diccionarios con comprobanteVE, remesaAgente y remesaSA. + """ + try: + root = ET.fromstring(xml_content) + + remesas = [] + for remesa in root.findall(".//ns3:remesas", self.namespaces): + comprobante = remesa.find("ns3:comprobanteVE", self.namespaces) + agente = remesa.find("ns3:remesaAgente", self.namespaces) + sa = remesa.find("ns3:remesaSA", self.namespaces) + + remesas.append({ + "comprobanteVE": comprobante.text if comprobante is not None else None, + "remesaAgente": agente.text if agente is not None else None, + "remesaSA": sa.text if sa is not None else None + }) + + return remesas + + except ET.ParseError as e: + print(f"Error al parsear XML: {e}") + return [] + except Exception as e: + print(f"Error inesperado: {e}") + return [] + +xml_controller = PedimentoScrapper() +xml_remesas_controller = XMLControllerRemesas() \ No newline at end of file diff --git a/docs/Flujo de datos/EFC/DataStage Dataflow (2).drawio b/docs/Flujo de datos/EFC/DataStage Dataflow (2).drawio new file mode 100644 index 0000000..de965b8 --- /dev/null +++ b/docs/Flujo de datos/EFC/DataStage Dataflow (2).drawio @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/Flujo de datos/EFC/DataStage Dataflow.drawio (2).svg b/docs/Flujo de datos/EFC/DataStage Dataflow.drawio (2).svg new file mode 100644 index 0000000..0f1ff1a --- /dev/null +++ b/docs/Flujo de datos/EFC/DataStage Dataflow.drawio (2).svg @@ -0,0 +1,4 @@ + + + +

VU Dataflow


VU Dataflow
Se carga el DS
Se carga el DS
Se obtienen los pedimentos y de divide en sus componentes
Registros (500 - 702)
Se obtienen los pedim...
Por cada pedimento
Por cada pedimento
Loop
Loop
No
No
Si pedimento completo
Si pedimento...
Obtener pedimento Completo
Obtener pediment...
while intentos <=5
while intentos <=5
Loop
Loop
Cada pedimento contara con 3 estados
- Procesado 1
- En espera 0 
- Fallido -1
ESTO APLICA PARA TODAS LAS TAREAS
Cada pedimento contara con 3 estados...
Se obtienen pedimentos con el estado de En espera 0
Se obtienen pediment...
Cambia el estado del pedimento a 1
Cambia el estado...
Se cambia estado de pedimento a -1
Se cambia estado...
Por cada pedimento
Por cada pedimento
Loop
Loop
No
No
Si pedimento completo
Si pedimento...
Obtener pedimento Completo
Obtener pediment...
while intentos <=5
while intentos <=5
Loop
Loop
Cambia el estado del pedimento a 1
Cambia el estado...
Se cambia estado de pedimento a -1
Se cambia estado...
Tarea Estado Pedimento
Tarea Estado Pedimento
Tarea de Edocument
Tarea de Edocument
Tareas
- Estado del Pedimento
- Pedimento Completo
- Partidas
- Remesas
- Acuses
-  Edocument
Tareas...
Inicio
Inicio
Fin
Fin
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/Flujo de datos/SCAII/Carga de datos de SCAII_Winsaii (1).drawio b/docs/Flujo de datos/SCAII/Carga de datos de SCAII_Winsaii (1).drawio new file mode 100644 index 0000000..1e51c32 --- /dev/null +++ b/docs/Flujo de datos/SCAII/Carga de datos de SCAII_Winsaii (1).drawio @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/Flujo de datos/SCAII/Carga de datos de SCAII_Winsaii.drawio (1).svg b/docs/Flujo de datos/SCAII/Carga de datos de SCAII_Winsaii.drawio (1).svg new file mode 100644 index 0000000..7bb741e --- /dev/null +++ b/docs/Flujo de datos/SCAII/Carga de datos de SCAII_Winsaii.drawio (1).svg @@ -0,0 +1,4 @@ + + + +
Se obtienen pedimentos de SCAII
(DB)
Se obtienen pedimento...
Se obtienen pedimentos de EFC (API)
Se obtienen pedimento...
Se eliminan Pedimentos en Comun
Se eliminan Pedime...
Quedan pedimentos que no se han cargado a EFC
Quedan pedimentos qu...
Se suben utilizando la API de EFC
Se suben utiliz...
Se crea el Procesamiento via API con los siguientes paramentos.
En Espera
id_pedimento
id_tipo_procesamiento
id_servicio
Se crea el Procesamiento v...
Se Agrega el servicio de procesamiento para cada uno de los servicios
Se Agrega el servicio d...
Se obtienen:
- Patentes
- Aduana 
- Tipo Operacion
- Clave pedimento
- Agente Aduanal
Se obtienen:...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/db/database_schema.dbml b/docs/db/database_schema.dbml new file mode 100644 index 0000000..1718909 --- /dev/null +++ b/docs/db/database_schema.dbml @@ -0,0 +1,176 @@ +// DBML generated from Django models +// Project: EFC_V2 + +// Table definitions + +Table organizacion { + id uuid [pk] + licencia int [ref: > licencia.id] + is_agente_aduanal boolean + nombre varchar(100) + rfc varchar(25) + titular varchar(200) + email varchar(100) + telefono varchar(25) + estado varchar(50) + ciudad varchar(50) + is_active boolean + is_verified boolean + inicio date + vencimiento date + created_at datetime + updated_at datetime + observaciones text +} + +Table uso_almacenamiento { + id int [pk, increment] + organizacion uuid [ref: - organizacion.id, unique] + espacio_utilizado bigint +} + +Table organizacion_configuracion { + id int [pk, increment] + organizacion uuid [ref: - organizacion.id, unique] + membretado varchar + membretado_2 varchar +} + +Table licencia { + id int [pk, increment] + nombre varchar(100) + descripcion text + almacenamiento int +} + +Table customuser { + id uuid [pk] + organizacion uuid [ref: > organizacion.id] + profile_picture varchar + is_importador boolean + rfc varchar(13) + username varchar(150) +} + +Table vucem { + id uuid [pk] + created_by uuid [ref: > customuser.id] + updated_by uuid [ref: > customuser.id] + organizacion uuid [ref: > organizacion.id] + usuario varchar(100) + password varchar(100) + patente varchar(100) + is_importador boolean + acusecove boolean + acuseedocument boolean + is_active boolean + created_at datetime + updated_at datetime +} + +Table CredencialesImportador { + id uuid [pk] + organizacion uuid [ref: > organizacion.id] + vucem uuid [ref: > vucem.id] + user uuid [ref: > customuser.id] + rfc varchar(13) + created_at datetime + updated_at datetime +} + +Table datastage { + id int [pk, increment] + nombre varchar(100) + almacenamiento int + organizacion uuid [ref: > organizacion.id] + archivo varchar + created_at datetime + updated_at datetime +} + +Table tipo_notificacion { + id int [pk, increment] + tipo varchar(100) + descripcion varchar(200) +} + +Table notificaciones { + id int [pk, increment] + tipo int [ref: > tipo_notificacion.id] + dirigido uuid [ref: > customuser.id] + mensaje text + fecha_envio datetime + created_at datetime + visto boolean +} + +Table documenttype { + id int [pk, increment] + nombre varchar(100) +} + +Table pedimento { + id uuid [pk] + pedimento varchar(20) + organizacion uuid [ref: > organizacion.id] + patente varchar(20) + aduana varchar(10) + regimen varchar(10) + tipo_operacion int [ref: > tipo_operacion.id] + clave_pedimento varchar(10) + fecha_inicio date + fecha_fin date + fecha_pago date + alerta boolean + contribuyente varchar(100) + agente_aduanal varchar(100) + curp_apoderado varchar(18) + importe_total decimal(10,2) + saldo_disponible decimal(10,2) + importe_pedimento decimal(10,2) + existe_expediente boolean + remesas boolean + numero_partidas int + numero_operacion varchar(20) + created_at datetime +} + +Table tipo_operacion { + id int [pk, increment] + tipo varchar(100) + descripcion varchar(200) +} + +Table document { + id uuid [pk] + organizacion uuid [ref: > organizacion.id] + pedimento uuid [ref: > pedimento.id] + archivo varchar(400) + document_type int [ref: > documenttype.id] + extension varchar(60) + size int + created_at datetime + updated_at datetime +} + +Table logger_request_log { + id int [pk, increment] + user uuid [ref: > customuser.id] + ip_address varchar + user_agent text + method varchar(10) + path varchar(500) + query_params text + body text + status_code int + response_time float + timestamp datetime + referer varchar(500) +} + +Table useractivity { + id int [pk, increment] + user uuid [ref: > customuser.id] + action varchar(20) + object_type varchar(100) +} diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/mixins/__init__.py b/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mixins/filtrado_organizacion.py b/mixins/filtrado_organizacion.py new file mode 100644 index 0000000..d232d1b --- /dev/null +++ b/mixins/filtrado_organizacion.py @@ -0,0 +1,142 @@ +import logging +logger = logging.getLogger(__name__) + +class FiltroPorOrganizacionMixin: + model = None + campo_usuario = 'user' + campo_organizacion = 'organizacion' + campo_rfc = 'rfc' + campo_contribuyente = 'pedimento__contribuyente' # solo si aplica + + def get_queryset_filtrado(self): + user = self.request.user + + if not user.is_authenticated or not hasattr(user, self.campo_organizacion): + return self.model.objects.none() + + if user.is_superuser: + return self.model.objects.all() + + if (user.groups.filter(name='admin').exists() or user.groups.filter(name='developer').exists()) and user.is_authenticated and user.groups.filter(name='Agente Aduanal').exists(): + model_fields = [f.name for f in self.model._meta.get_fields()] + if self.campo_organizacion in model_fields: + filtro = {f"{self.campo_organizacion}": getattr(user, self.campo_organizacion)} + else: + return self.model.objects.none() + return self.model.objects.filter(**filtro) + + if user.groups.filter(name='Importador').exists() and getattr(user, 'is_importador', False): + filtro = { + f"{self.campo_contribuyente}__{self.campo_rfc}": getattr(user, self.campo_rfc), + } + return self.model.objects.filter(**filtro) + + return self.model.objects.none() + +# en core/mixins/organizacion.py o similar +class OrganizacionFiltradaMixin: + model = None # Puedes sobreescribir esto en la vista + campo_organizacion = 'organizacion' + campo_contribuyente = 'contribuyente' # solo si aplica + + def get_queryset_filtrado_por_organizacion(self): + model = self.model or self.queryset.model + + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return model.objects.none() + + if self.request.user.is_superuser: + return model.objects.all() + + org = self.request.user.organizacion + filtros_base = { + f"{self.campo_organizacion}": org, + f"{self.campo_organizacion}__is_active": True, + f"{self.campo_organizacion}__is_verified": True, + } + + grupos = self.request.user.groups.values_list('name', flat=True) + + if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and (('admin' in grupos or 'developer' in grupos) and 'user' in grupos) : + if 'Agente Aduanal' in grupos: + return model.objects.filter(**filtros_base) + + if hasattr(model, self.campo_contribuyente): + if self.request.user.is_authenticated and'Importador' in grupos and getattr(self.request.user, 'is_importador', False): + filtros_base[f"{self.campo_contribuyente}"] = self.request.user.rfc + return model.objects.filter(**filtros_base) + + # Si no entra en los roles válidos + return model.objects.none() + +class DocumentosFiltradosMixin: + model = None + campo_organizacion = 'organizacion' + campo_contribuyente = 'pedimento' # solo si aplica + + def get_queryset_filtrado_por_organizacion(self): + model = self.model or self.queryset.model + + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return model.objects.none() + + if self.request.user.is_superuser: + return model.objects.all() + + org = self.request.user.organizacion + filtros_base = { + f"{self.campo_organizacion}": org.id, + f"{self.campo_organizacion}__is_active": True, + f"{self.campo_organizacion}__is_verified": True, + } + + grupos = self.request.user.groups.values_list('name', flat=True) + + if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and ('admin' in grupos or 'developer' in grupos or 'user' in grupos): + if 'Agente Aduanal' in grupos: + return model.objects.filter(**filtros_base) + + if hasattr(model, self.campo_contribuyente): + if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False): + filtros_base[f"{self.campo_contribuyente}__contribuyente"] = self.request.user.rfc + return model.objects.filter(**filtros_base) + + # Si no entra en los roles válidos + return model.objects.none() + +class ProcesosPorOrganizacionMixin: + model = None # Puedes sobreescribir esto en la vista + campo_organizacion = 'organizacion' + campo_pedimento = 'pedimento' # solo si aplica + + def get_queryset_filtrado_por_organizacion(self): + model = self.model or self.queryset.model + + if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + return model.objects.none() + + if self.request.user.is_superuser: + return model.objects.all() + + org = self.request.user.organizacion + filtros_base = { + f"{self.campo_organizacion}": org, + f"{self.campo_organizacion}__is_active": True, + f"{self.campo_organizacion}__is_verified": True, + } + + grupos = self.request.user.groups.values_list('name', flat=True) + + + if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and ('admin' in grupos or 'developer' in grupos or 'user' in grupos) : + if 'Agente Aduanal' in grupos: + return model.objects.filter(**filtros_base) + + if hasattr(model, self.campo_pedimento): + if self.request.user.is_authenticated and'Importador' in grupos and getattr(self.request.user, 'is_importador', False): + filtros_base[f"{self.campo_pedimento}__contribuyente"] = self.request.user.rfc + return model.objects.filter(**filtros_base) + + # Si no entra en los roles válidos + return model.objects.none() + diff --git a/registros.json b/registros.json new file mode 100644 index 0000000..b8d3459 --- /dev/null +++ b/registros.json @@ -0,0 +1,152 @@ +[ + { + "model": "Registro500", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "consecutivo_remesa", "numero_seleccion", "fecha_inicio_reconocimiento", "hora_inicio_reconocimiento", "fecha_fin_reconocimiento", "hora_fin_reconocimiento", "fraccion", "secuencia_fraccion", "clave_documento", "tipo_operacion", "grado_incidencia", "fecha_seleccion", "organizacion", "consulta", "datastage", "created_at", "updated_at"], + "filters": {"patente": "1234", "pedimento": "5678901", "seccion_aduanera": "001", "fecha_seleccion": "2023-01-03", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro501", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "tipo_operacion", "clave_documento", "seccion_aduanera_entrada", "curp_contribuyente", "rfc", "curp_agente_a"], + "filters": {"patente": "1234", "pedimento": "5678901", "seccion_aduanera": "001", "tipo_operacion": "IMPORT", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro502", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "tipo_operacion", "clave_documento", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "pedimento": "5678901", "fecha_pago_real": "2023-01-01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro503", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "tipo_operacion", "clave_documento", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "pedimento": "5678901", "fecha_pago_real": "2023-01-01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro504", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "num_contenedor", "tipo_contenedor", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "pedimento": "5678901", "num_contenedor": "CONT001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro505", + "fields": ["id", "pedimento", "seccion_aduanera", "fecha_facturacion", "numero_factura", "termino_facturacion", "moneda_facturacion", "valor_dolares", "valor_moneda_extranjera", "pais_facturacion", "entidad_fed_facturacion", "indent_fiscal_proveedor", "proveedor_mercancia", "calle_proveedor", "num_interior_proveedor", "num_exterior_proveedor", "cp_proveedor", "municipio_proveedor", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage", "patente"], + "filters": {"pedimento": "5678901", "numero_factura": "FAC001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro506", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "tipo_fecha", "fecha_operacion", "fecha_validacion_pago_r", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "fecha_operacion": "2023-01-01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro507", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "clave_caso", "identificador_caso", "tipo_pedimento", "complemento_caso", "fecha_validacion_pago_r", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "clave_caso": "CASO001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro508", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "institucion_emisora", "numero_cuenta", "folio_constancia", "fecha_constancia", "tipo_cuenta", "clave_garantia", "valor_unitario_titulo", "total_garantia", "cantidad_unidades", "titulos_asignados", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "institucion_emisora": "IE01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro509", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "clave_contribucion", "tasa_contribucion", "tipo_tasa", "tipo_pedimento", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "clave_contribucion": "CC01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro510", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "clave_contribucion", "tasa_contribucion", "tipo_tasa", "tipo_pedimento", "fecha_pago_real", "organizacion", "created_by", "datastage", "consulta", "forma_pago", "importe_pago"], + "filters": {"patente": "1234", "clave_contribucion": "CC01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro511", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "secuencia_observacion", "observaciones", "tipo_pedimento", "fecha_validacion_pago_r", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "secuencia_observacion": "OBS01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro512", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "patente_aduanal_orig", "pedimento_original", "seccion_aduanera_desp_orig", "documento_original", "fecha_operacion_orig", "fraccion_original", "unidad_medida", "mercancia_descargada", "tipo_pedimento", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "patente_aduanal_orig": "PAO01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro520", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "indent_fiscal_destinatario", "nombre_destinatario_mercancia", "calle_destinatario", "num_interior_destinatario", "num_exterior_destinatario", "cp_destinatario", "municpio_destinatario", "pais_destinatario", "fecha_pago_real", "organizacion", "created_at", "consulta", "datastage"], + "filters": {"patente": "1234", "indent_fiscal_destinatario": "IFD01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro551", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "fraccion", "secuencia_fraccion", "subdivision_fraccion", "descripcion_mercancia", "precio_unitario", "valor_aduana", "valor_comercial", "valor_dolares", "cantidad_um_comercial", "unidad_medida_comercial", "cantidad_um_tarifa", "unidad_medida_tarifa", "valor_agregado", "clave_vinculacion", "metodo_valorizacion", "codigo_mercancia_producto", "marca_mercancia_producto", "modelo_mercancia_producto", "pais_origen_destino", "pais_comprador_vendedor", "entidad_fed_origen", "entidad_fed_comprador", "entidad_fed_vendedor", "tipo_operacion", "clave_documento", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage", "entidad_fed_destino"], + "filters": {"patente": "1234", "fraccion": "FRAC001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro552", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "fraccion", "secuencia_fraccion", "vin_numero_serie", "kilometraje_vehiculo", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "fraccion": "FRAC001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro553", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "fraccion", "secuencia_fraccion", "clave_permiso", "firma_descargo", "numero_permiso", "valor_comercial_dolares", "cantidad_mum_tarifa", "fecha_pago_real", "organizacion", "created_by", "datastage", "consulta"], + "filters": {"patente": "1234", "fraccion": "FRAC001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro554", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "fraccion", "secuencia_fraccion", "clave_caso", "identificador_caso", "complemento_caso", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "fraccion": "FRAC001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro555", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "fraccion", "secuencia_fraccion", "institucion_emisora", "numero_cuenta", "folio_constancia", "fecha_constancia", "clave_garantia", "valor_unitario_titulo", "total_garantia", "cantidad_unidades_medida", "titulos_asignados", "fecha_pago_real", "organizacion", "datastage", "created_by", "consulta"], + "filters": {"patente": "1234", "fraccion": "FRAC001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro556", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "clave_contribucion", "tasa_contribucion", "tipo_tasa", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage", "fraccion", "secuencia_fraccion"], + "filters": {"patente": "1234", "clave_contribucion": "CC01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro557", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "fraccion", "secuencia_fraccion", "clave_contribucion", "forma_pago", "importe_pago", "fecha_pago_real", "organizacion", "created_by", "datastage", "consulta"], + "filters": {"patente": "1234", "fraccion": "FRAC001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro558", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "fraccion", "secuencia_fraccion", "secuencia_observacion", "observaciones", "fecha_pago_real", "organizacion", "created_by", "datastage", "consulta"], + "filters": {"patente": "1234", "fraccion": "FRAC001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "RegistroSel", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "consecutivo_remesa", "numero_seleccion", "fecha_seleccion", "hora_seleccion", "semaforo_fiscal", "clave_documento", "tipo_operacion", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "consecutivo_remesa": "REM001", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro701", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "clave_documento", "fecha_pago", "pedimento_anterior", "patente_anterior", "seccion_aduanera_anterior", "documento_anterior", "fecha_operacion_anterior", "pedimento_original", "patente_aduanal_orig", "seccion_aduanera_desp_orig", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "pedimento_anterior": "PEDANT01", "organizacion": 1, "datastage": 1}, + "type": "excel" + }, + { + "model": "Registro702", + "fields": ["id", "patente", "pedimento", "seccion_aduanera", "clave_contribucion", "forma_pago", "importe_pago", "tipo_pedimento", "fecha_pago_real", "organizacion", "created_by", "consulta", "datastage"], + "filters": {"patente": "1234", "clave_contribucion": "CC01", "organizacion": 1, "datastage": 1}, + "type": "excel" + } +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..361dc10 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,68 @@ +alembic==1.14.0 +amqp==5.3.1 +annotated-types==0.7.0 +asgiref==3.9.1 +async-timeout==5.0.1 +attrs==25.3.0 +billiard==4.2.1 +celery==5.5.3 +certifi==2025.6.15 +channels==4.3.1 +channels_redis==4.3.0 +charset-normalizer==3.4.2 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +Django==5.2.3 +django-cors-headers==4.7.0 +django-filter==25.1 +django-jet-reboot==1.3.10 +djangorestframework==3.16.0 +djangorestframework_simplejwt==5.5.0 +drf-yasg==1.21.10 +et_xmlfile==2.0.0 +flower==2.0.1 +greenlet==3.2.3 +gunicorn==23.0.0 +h11==0.16.0 +humanize==4.12.3 +idna==3.10 +importlib_resources==6.5.2 +inflection==0.5.1 +jsonschema==4.24.0 +jsonschema-specifications==2025.4.1 +kombu==5.5.4 +Mako==1.3.10 +Markdown==3.8 +MarkupSafe==3.0.2 +msgpack==1.1.1 +openpyxl==3.1.5 +packaging==25.0 +passlib==1.7.4 +pillow==11.2.1 +prometheus_client==0.22.1 +prompt_toolkit==3.0.51 +psycopg2-binary==2.9.10 +PyJWT==2.9.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 +python-multipart==0.0.12 +pytz==2025.2 +PyYAML==6.0.2 +redis==6.2.0 +referencing==0.36.2 +requests==2.32.4 +rpds-py==0.25.1 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.36 +sqlparse==0.5.3 +swagger-spec-validator==3.0.4 +tornado==6.5.1 +typing_extensions==4.14.0 +tzdata==2025.2 +uritemplate==4.2.0 +urllib3==2.5.0 +vine==5.1.0 +wcwidth==0.2.13 diff --git a/script/__init__.py b/script/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/script/script.py b/script/script.py new file mode 100644 index 0000000..cbfe947 --- /dev/null +++ b/script/script.py @@ -0,0 +1,174 @@ +import os +import sys + +# Ajusta la ruta al directorio raíz del proyecto +sys.path.insert(0, '/home/kevinarm/Documents/EFC_V2/backend') + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +import django +django.setup() + +# Ahora puedes importar tus modelos +from api.customs.models import Pedimento +from api.record.models import Fuente + + +""" +id nombre descripcion +1 Pedimento Partida Tag para saber que el archivo guarda una partida +2 Pedimento Completo Tag para saber que el archivo guarda un pedimento completo +3 Pedimento Remesas Tag para saber que el documento almacena remesas +5 Pedimento EDocument Tag para saber que el documento es un EDocument +6 Estado Pedimento Tag para saber que el archivo almacena el Estado del pedimeto +4 Pedimento Acuse Tag para saber que el documento es un Acuse +7 Acuse Cove Tag para saber que el archivo guarda un acuse de cove +8 Cove Tag para saber que el archivo guarda un cove +9 Documento Digitalizacion Tag para saber que es un documento logistico +" +""" +FUENTE = Fuente.objects.filter(pk=1).first() # Asumiendo que la fuente es siempre 1, ajusta según sea necesario +TIPOS_DE_DOCUMENTOS = { + 'PT': '1', + 'AC': '4', + 'PC': '2', + 'EDC': '5', + 'AC_COVE': '7', + 'COVE': '8', + 'RM': '3', + 'LOGISTICO': '9', +} + + +class DocumentFile: + def __init__(self, document): + self.document_name = document + self.document_type = self.get_pedimento_tipo() + self.fuente = self.get_fuente() + self.extension = self.get_extension() + self.aduana = self.get_aduana() + self.patente = self.get_patente() + self.pedimento = self.get_pedimento_numero() + self.numero_documento = self.get_numero_documento() + self.identificador = self.get_identificacor() + + + def get_fuente(self): + return self.document_name.split('_')[0] if '_' in self.document_name else None + + def get_extension(self): + return self.document_name.split('.')[-1] if '.' in self.document_name else None + + def get_aduana(self): + if self.document_type != 'AC_COVE': + return self.document_name.split('_')[3] if '_' in self.document_name else None + return self.document_name.split('_')[4] if '_' in self.document_name else None + + def get_patente(self): + if self.document_type != 'AC_COVE': + return self.document_name.split('_')[4] if '_' in self.document_name else None + return self.document_name.split('_')[5] if '_' in self.document_name else None + + def get_pedimento_numero(self): + if self.document_type != 'AC_COVE': + return self.document_name.split('_')[5] if '_' in self.document_name else None + return self.document_name.split('_')[6] if '_' in self.document_name else None + + def get_identificacor(self): + if self.document_type != 'AC_COVE': + return self.document_name.split('_')[2] if '_' in self.document_name else None + return self.document_name.split('_')[3] if '_' in self.document_name else None + + def get_numero_documento(self): + if self.document_type != 'AC_COVE' and self.document_type in ('PT', 'EDC', 'AC', 'COVE'): + return self.document_name.split('_')[6] if '_' in self.document_name else None + elif self.document_type in ('RM', 'PC'): + return 0 + return self.document_name.split('_')[7] if '_' in self.document_name else None + + def is_duplicated(self): + if self.document_type != 'AC_COVE' and self.document_type != 'RM' and self.document_type != 'PC': + return False if len(self.document_name.split('_')) <= 7 else True + elif self.document_type == 'RM' or self.document_type == 'PC': + return False if len(self.document_name.split('_')) <= 6 else True + elif self.document_type == 'AC_COVE': + return False if len(self.document_name.split('_')) <= 8 else True + else: + return False + + return self.document_name.split('_')[8] if '_' in self.document_name else None + + def get_pedimento_tipo(self): + if 'PT' in self.document_name: + return 'PT' + elif 'AC' in self.document_name and not 'COVE' in self.document_name: + return 'AC' + elif 'PC' in self.document_name: + return 'PC' + elif 'EDC' in self.document_name: + return 'EDC' + elif 'AC_COVE' in self.document_name: + return 'AC_COVE' + elif 'COVE' in self.document_name and not 'AC_COVE' in self.document_name: + return 'COVE' + elif 'LOGISTICO' in self.document_name: + return 'LOGISTICO' + elif 'RM' in self.document_name: + return 'RM' + else: + return None + + + def get_pedimento(self): + from api.customs.models import Pedimento + return Pedimento.objects.filter(pedimento=self.pedimento).first() + + def document_exists(self, pedimento): + from api.record.models import Document + if self.document_type in ('PT', 'EDC', 'AC', 'COVE', 'AC_COVE'): + doc_name = f"{self.fuente}_{self.document_type}_{self.aduana}_{self.patente}_{self.pedimento}_{self.numero_documento}.{self.extension}" + elif self.document_type in ('PC', 'RM'): + doc_name = f"{self.fuente}_{self.document_type}_{self.aduana}_{self.patente}_{self.pedimento}.{self.extension}" + return Document.objects.filter(archivo=doc_name , pedimento=pedimento).exists() + + def __str__(self): + return f"{self.pedimento}" + +def get_document_type(document_type): + from api.record.models import DocumentType + return DocumentType.objects.filter(pk=TIPOS_DE_DOCUMENTOS[document_type]).first() + +import concurrent.futures + +def process_document(document, path): + from api.record.models import Document + doc = DocumentFile(document) + print(document) + pedimento = doc.get_pedimento() + if pedimento: + document_exists = doc.document_exists(pedimento) + if not document_exists and not doc.is_duplicated(): + Document.objects.create( + organizacion=pedimento.organizacion, + pedimento=pedimento, + archivo=document, + document_type=get_document_type(doc.document_type), + fuente=FUENTE, # Asumiendo que la fuente es siempre 1, ajusta según sea necesario + extension=doc.extension, + size=os.path.getsize(os.path.join(path, document)), + ) + print(f"El documento {doc.document_name} pertenece al pedimento {doc.pedimento} y se ha creado en la base de datos.") + else: + print(f"El documento {doc.document_name} pertenece al pedimento {doc.pedimento} y ya existe en la base de datos.") + else: + print(f"El documento {doc.document_name} pertenece al pedimento {doc.pedimento} y NO existe en la base de datos.") + +def main(): + path = '/app/media/documents' + documents = os.listdir(path) + max_workers = os.cpu_count() or 1 + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + executor.map(lambda doc: process_document(doc, path), documents) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ssi.md b/ssi.md new file mode 100644 index 0000000..00d7c7b --- /dev/null +++ b/ssi.md @@ -0,0 +1,50 @@ +# ¿Cómo funciona el acceso único (SSO) en nuestra plataforma? + +**SSO** (Single Sign-On) permite que los usuarios accedan a nuestra plataforma usando la misma cuenta corporativa que ya utilizan en su empresa (por ejemplo, la de Microsoft, Google, etc.), sin tener que crear o recordar una contraseña nueva. + +## ¿Qué ocurre cuando entras a la plataforma? + +1. **Intentas acceder** a la plataforma desde tu navegador o app. +2. **La plataforma te redirige** automáticamente a la página de inicio de sesión de tu empresa. +3. **Te identificas** con tu usuario y contraseña corporativos (o con el método que tu empresa use: huella, código, etc.). +4. **La empresa confirma tu identidad** y le avisa a la plataforma que eres tú. +5. **Accedes directamente** a la plataforma, sin tener que registrarte ni poner otra contraseña. + +## ¿Por qué es seguro y conveniente? + +- Solo usas tu cuenta corporativa, no tienes que crear ni recordar otra contraseña. +- Si tu empresa usa autenticación de dos pasos (MFA), también se aplica aquí. +- Si tu empresa desactiva tu cuenta, automáticamente pierdes acceso a la plataforma. +- Todo el proceso es automático y transparente para el usuario. + +## ¿Qué necesita tu empresa? + +- Tener un sistema de identidad compatible (Microsoft, Google, Okta, etc.). +- Compartirnos los datos de conexión (esto lo hace el área de TI, no el usuario final). +- ¡Nada más! El resto lo configuramos nosotros. + +--- + +## Ejemplo visual del acceso SSO + +``` ++-------------------+ +-------------------+ +-------------------+ +| | | | | | +| Usuario Final | <-----> | Nuestra | <-----> | Identidad | +| (Tú, en tu PC) | | Plataforma | | Corporativa | +| | | | | (Microsoft, | +| | | | | Google, etc.) | ++-------------------+ +-------------------+ +-------------------+ + | | | + |--- 1. Acceso a la app ------>| | + | |--- 2. Redirige a login ----->| + | | | + |<-- 5. Acceso directo --------|<-- 3. Login y validación ----| + | |--- 4. Confirmación ----------| + | | | +``` + +- El usuario entra a la plataforma. +- Es redirigido automáticamente al login de su empresa. +- Se valida su identidad. +- Vuelve a la plataforma ya autenticado, sin más contraseñas. \ No newline at end of file diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..d15a1f5 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,89 @@ +[supervisord] +nodaemon=true +user=root + +[program:collectstatic] +command=python manage.py collectstatic --noinput +directory=/app +autostart=true +autorestart=false +startretries=1 +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +priority=1 + +[program:django] +command=gunicorn --bind 0.0.0.0:8000 --workers 12 --timeout 120 config.wsgi:application +directory=/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 + +[program:celery-worker-1] +command=celery -A config worker --loglevel=info --concurrency=6 --hostname=worker1@%%h +directory=/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +killasgroup=true +stopasgroup=true + +[program:celery-worker-2] +command=celery -A config worker --loglevel=info --concurrency=6 --hostname=worker2@%%h +directory=/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +killasgroup=true +stopasgroup=true + +[program:celery-worker-3] +command=celery -A config worker --loglevel=info --concurrency=6 --hostname=worker3@%%h +directory=/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +killasgroup=true +stopasgroup=true + +[program:celery-worker-4] +command=celery -A config worker --loglevel=info --concurrency=6 --hostname=worker4@%%h +directory=/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +killasgroup=true +stopasgroup=true + +[program:celery-beat] +command=celery -A config beat --loglevel=info +directory=/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +killasgroup=true +stopasgroup=true + +[group:celery] +programs=celery-worker-1,celery-worker-2,celery-worker-3,celery-worker-4,celery-beat + diff --git a/templates/admin/dashboard_admin.html b/templates/admin/dashboard_admin.html new file mode 100644 index 0000000..4179a54 --- /dev/null +++ b/templates/admin/dashboard_admin.html @@ -0,0 +1,24 @@ +{% load static %} +
+ +
+ + + diff --git a/templates/email/activation_email.html b/templates/email/activation_email.html new file mode 100644 index 0000000..99ddcc8 --- /dev/null +++ b/templates/email/activation_email.html @@ -0,0 +1,44 @@ + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + +

¡Bienvenido, {{ username }}!

+
+

Por favor haz clic en el siguiente botón para activar tu cuenta:

+ + + + +
+ Activar cuenta +
+

Si no solicitaste este registro, ignora este correo.

+
+
+

EFC V2 © {{ year|default:2025 }}
Todos los derechos reservados.

+
+
+ + diff --git a/templates/email/password_reset_email.html b/templates/email/password_reset_email.html new file mode 100644 index 0000000..aed6c32 --- /dev/null +++ b/templates/email/password_reset_email.html @@ -0,0 +1,44 @@ + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + +

Restablece tu contraseña

+
+

Hola, {{ username }}.
Para restablecer tu contraseña haz clic en el siguiente botón:

+ + + + +
+ Restablecer contraseña +
+

Si no solicitaste este cambio, ignora este correo.

+
+
+

EFC V2 © {{ year|default:2025 }}
Todos los derechos reservados.

+
+
+ +