| @ -0,0 +1,40 @@ | |||
| # yup, python 3.11! | |||
| FROM python:3.11-slim | |||
| # install nginx y otras cosas | |||
| RUN apt-get update && apt-get install nginx netcat-openbsd curl vim jq rsync -y | |||
| # copy our nginx configuration to overwrite nginx defaults | |||
| RUN rm /etc/nginx/sites-enabled/default | |||
| RUN rm /etc/nginx/sites-available/default | |||
| COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf | |||
| # link nginx logs to container stdout | |||
| RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log | |||
| # copy the django code | |||
| COPY ./src ./app | |||
| RUN chgrp -R 0 ./app && chmod -R g=u ./app | |||
| RUN chgrp -R 0 /var/lib/nginx && chmod -R g=u /var/lib/nginx | |||
| # change our working directory to the django projcet roo | |||
| WORKDIR /app | |||
| # create virtual env (notice the location?) | |||
| # update pip | |||
| # install requirements | |||
| RUN python -m venv /opt/venv && \ | |||
| /opt/venv/bin/python -m pip install pip --upgrade && \ | |||
| /opt/venv/bin/python -m pip install -r requirements.txt | |||
| # Añade path a .bashrc | |||
| RUN echo "export PATH=/opt/venv/bin/:$PATH" >> /root/.bashrc | |||
| # make our entrypoint.sh executable | |||
| RUN chmod +x config/entrypoint.sh | |||
| #EXPOSE 8080 | |||
| # execute our entrypoint.sh file | |||
| CMD ["./config/entrypoint.sh"] | |||
| @ -0,0 +1,98 @@ | |||
| # Versión para openshift | |||
| ## Instalación | |||
| Crear un proyecto. | |||
| ### Running Commands as Root in OpenShift | |||
| oc adm policy add-scc-to-user anyuid -z default | |||
| ### app trainersapp | |||
| A continuación añadir una app desde Agregar, importar desde git. | |||
| oc new-app https://gogs.reymota.es/creylopez/TrainersAppDj.git -e DEBUG="False" --name='trainersapp' | |||
| Tal y como está la estructura de directorios, deberia detectar automáticamente una compilación Python | |||
| ## asignación de los volúmenes | |||
| ### Si la pvc no está creada | |||
| oc set volume deployment.apps/reymota --add -t pvc --claim-size=300M --name=reymota-lyrics-migrations --claim-name='reymota-lyrics-migrations' --mount-path='/app/lyrics/migrations' | |||
| oc set volume deployment.apps/reymota --add -t pvc --claim-size=300M --name=reymota-media --claim-name='reymota-media' --mount-path='/app/mediafiles' | |||
| oc set volume deployment.apps/reymota --add -t pvc --claim-size=300M --name=reymota-repostajes-migrations --claim-name='reymota-repostajes-migrations' --mount-path='/app/repostajes/migrations' | |||
| oc set volume deployment.apps/reymota --add -t pvc --claim-size=300M --name=reymota-reymotausers-migrations --claim-name='reymota-reymotausers-migrations' --mount-path='/app/reymotausers/migrations' | |||
| oc set volume deployment.apps/reymota --add -t pvc --claim-size=50G --name=static-volume --claim-name='static-volume' --mount-path='/app/staticfiles' | |||
| ### Si la pvc ya está creada | |||
| oc set volume deployment.apps/reymota --add -t pvc --name=reymota-lyrics-migrations --claim-name='reymota-lyrics-migrations' --mount-path='/app/lyrics/migrations' | |||
| oc set volume deployment.apps/reymota --add -t pvc --name=reymota-media --claim-name='reymota-media' --mount-path='/app/mediafiles' | |||
| oc set volume deployment.apps/reymota --add -t pvc --name=reymota-repostajes-migrations --claim-name='reymota-repostajes-migrations' --mount-path='/app/repostajes/migrations' | |||
| oc set volume deployment.apps/reymota --add -t pvc --name=reymota-reymotausers-migrations --claim-name='reymota-reymotausers-migrations' --mount-path='/app/reymotausers/migrations' | |||
| oc set volume deployment.apps/reymota --add -t pvc --name=static-volume --claim-name='static-volume' --mount-path='/app/staticfiles' | |||
| ## Exponer el servicio | |||
| oc expose deployment.apps/reymota --type=NodePort --port=8080 | |||
| ### postgresql | |||
| Se hace desde el yaml | |||
| oc create -f postgresql-deployment.yaml | |||
| ## Modificaciones al código | |||
| Una vez realizadas las modificaciones al código y se hayan subido a gitea, hay que reconstruir el proyecto. | |||
| oc start-build reymota | |||
| ## Comandos a ejecutar la primera vez o cuando haya cambios en las bases de datos | |||
| python manage.py createsuperuser | |||
| python manage.py makemigrations | |||
| python manage.py migrate | |||
| ## Comprobar la base de datos | |||
| Con la shell entraPsql.sh: | |||
| \l para listar las BD | |||
| \c reymota para usar nuestra db | |||
| \dt para ver las tablas | |||
| # De dónde cogí ideas | |||
| https://learndjango.com/tutorials/django-login-and-logout-tutorial | |||
| Username: {{ user.username }} | |||
| User Full name: {{ user.get_full_name }} | |||
| User Group: {{ user.groups.all.0 }} | |||
| Email: {{ user.email }} | |||
| Session Started at: {{ user.last_login }} | |||
| ## Para funcionar con gunicorn y nginx | |||
| https://testdriven.io/blog/dockerizing-django-with-postgres-gunicorn-and-nginx/ | |||
| ## Cambiar la secuencia de lo sid | |||
| ALTER SEQUENCE tablename_id_seq RESTART WITH nn; | |||
| esto se hace cuando restauro un volcado de la bd sobre una instalación nueva. Si hay índices ya creados, hay que reinciar a partir del último. | |||
| @ -0,0 +1,11 @@ | |||
| apiVersion: v1 | |||
| data: | |||
| POSTGRES_DB: entrenadores | |||
| POSTGRES_PASSWORD: Dsa-0213 | |||
| POSTGRES_USER: creylopez | |||
| kind: ConfigMap | |||
| metadata: | |||
| labels: | |||
| io.kompose.service: db-env-prod-db | |||
| name: env-prod-db | |||
| namespace: trainersapp | |||
| @ -0,0 +1,52 @@ | |||
| apiVersion: apps/v1 | |||
| kind: Deployment | |||
| metadata: | |||
| annotations: | |||
| kompose.cmd: kompose convert | |||
| kompose.version: 1.34.0 (cbf2835db) | |||
| labels: | |||
| io.kompose.service: postgresql | |||
| name: postgresql | |||
| namespace: trainersapp | |||
| spec: | |||
| replicas: 1 | |||
| selector: | |||
| matchLabels: | |||
| io.kompose.service: postgresql | |||
| strategy: | |||
| type: Recreate | |||
| template: | |||
| metadata: | |||
| annotations: | |||
| kompose.cmd: kompose convert | |||
| kompose.version: 1.34.0 (cbf2835db) | |||
| labels: | |||
| io.kompose.service: postgresql | |||
| spec: | |||
| containers: | |||
| - env: | |||
| - name: POSTGRES_DB | |||
| valueFrom: | |||
| configMapKeyRef: | |||
| key: POSTGRES_DB | |||
| name: env-prod-db | |||
| - name: POSTGRES_PASSWORD | |||
| valueFrom: | |||
| configMapKeyRef: | |||
| key: POSTGRES_PASSWORD | |||
| name: env-prod-db | |||
| - name: POSTGRES_USER | |||
| valueFrom: | |||
| configMapKeyRef: | |||
| key: POSTGRES_USER | |||
| name: env-prod-db | |||
| image: postgres:15 | |||
| name: postgresql | |||
| volumeMounts: | |||
| - mountPath: /var/lib/postgresql/data | |||
| name: postgresql | |||
| restartPolicy: Always | |||
| volumes: | |||
| - name: postgresql | |||
| persistentVolumeClaim: | |||
| claimName: postgresql | |||
| @ -0,0 +1,17 @@ | |||
| apiVersion: v1 | |||
| kind: Service | |||
| metadata: | |||
| annotations: | |||
| kompose.cmd: kompose convert | |||
| kompose.version: 1.34.0 (cbf2835db) | |||
| labels: | |||
| io.kompose.service: postgresql | |||
| name: postgresql | |||
| namespace: trainersapp | |||
| spec: | |||
| ports: | |||
| - name: "5432" | |||
| port: 5432 | |||
| targetPort: 5432 | |||
| selector: | |||
| io.kompose.service: postgresql | |||
| @ -0,0 +1,17 @@ | |||
| apiVersion: v1 | |||
| kind: PersistentVolumeClaim | |||
| metadata: | |||
| finalizers: | |||
| - kubernetes.io/pvc-protection | |||
| labels: | |||
| template: postgresql-persistent-template | |||
| name: postgresql | |||
| namespace: reymota | |||
| spec: | |||
| accessModes: | |||
| - ReadWriteOnce | |||
| resources: | |||
| requests: | |||
| storage: 1Gi | |||
| storageClassName: lvms-vg1 | |||
| volumeMode: Filesystem | |||
| @ -0,0 +1,13 @@ | |||
| apiVersion: v1 | |||
| kind: Service | |||
| metadata: | |||
| name: reymota | |||
| namespace: reymota | |||
| spec: | |||
| type: NodePort | |||
| ports: | |||
| - name: "8080" | |||
| port: 8080 | |||
| nodePort: 30341 | |||
| targetPort: 8080 | |||
| @ -0,0 +1,3 @@ | |||
| oc delete -f Yamls/env-prod-db-configmap.yaml | |||
| oc delete -f Yamls/postgresql-deployment.yaml | |||
| oc delete -f Yamls/postgresql-service.yaml | |||
| @ -0,0 +1,4 @@ | |||
| oc create -f Yamls/env-prod-db-configmap.yaml | |||
| #oc create -f Yamls/pvc-postgresql.yaml | |||
| oc create -f Yamls/postgresql-deployment.yaml | |||
| oc create -f Yamls/postgresql-service.yaml | |||
| @ -0,0 +1,32 @@ | |||
| upstream projecto_entrenadores { | |||
| server localhost:8000; | |||
| } | |||
| error_log /var/log/nginx/error.log; | |||
| server { | |||
| listen 8080; | |||
| access_log /var/log/nginx/access.log; | |||
| location / { | |||
| proxy_pass http://projecto_entrenadores; | |||
| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |||
| proxy_set_header Host $host; | |||
| proxy_redirect off; | |||
| client_max_body_size 100M; | |||
| } | |||
| location /static/ { | |||
| alias /app/staticfiles/; | |||
| } | |||
| location /media/ { | |||
| alias /app/mediafiles/; | |||
| } | |||
| error_page 500 502 503 504 /50x.html; | |||
| location = /50x.html { | |||
| root /usr/share/nginx/html; | |||
| } | |||
| } | |||
| @ -0,0 +1 @@ | |||
| oc new-app https://gogs.reymota.es/creylopez/TrainersAppDj.git -e DEBUG=True | |||
| @ -0,0 +1 @@ | |||
| migrations/ | |||
| @ -0,0 +1,3 @@ | |||
| from django.contrib import admin | |||
| # Register your models here. | |||
| @ -0,0 +1,6 @@ | |||
| from django.apps import AppConfig | |||
| class AccountsConfig(AppConfig): | |||
| default_auto_field = 'django.db.models.BigAutoField' | |||
| name = 'accounts' | |||
| @ -0,0 +1,3 @@ | |||
| from django.db import models | |||
| # Create your models here. | |||
| @ -0,0 +1,3 @@ | |||
| from django.test import TestCase | |||
| # Create your tests here. | |||
| @ -0,0 +1,8 @@ | |||
| # accounts/urls.py | |||
| from django.urls import path | |||
| from django.contrib.auth import views as auth_views | |||
| urlpatterns = [ | |||
| path('login/', auth_views.LoginView.as_view(), name='login'), | |||
| path('logout/', auth_views.LogoutView.as_view(), name='logout'), | |||
| ] | |||
| @ -0,0 +1 @@ | |||
| from django.shortcuts import render | |||
| @ -0,0 +1,27 @@ | |||
| #!/bin/bash | |||
| RUN_PORT="8000" | |||
| DATABASE=postgres | |||
| SQL_HOST=postgresql | |||
| SQL_PORT=5432 | |||
| export PATH=/opt/venv/bin:$PATH | |||
| if [ "$DATABASE" = "postgres" ] | |||
| then | |||
| echo "Waiting for postgres..." | |||
| while ! nc -z $SQL_HOST $SQL_PORT; do | |||
| sleep 0.1 | |||
| done | |||
| echo "PostgreSQL started" | |||
| /opt/venv/bin/python manage.py migrate --no-input | |||
| /opt/venv/bin/python manage.py collectstatic --no-input | |||
| /opt/venv/bin/gunicorn entrenadores.wsgi:application --bind "0.0.0.0:${RUN_PORT}" --daemon | |||
| nginx -g 'daemon off;' | |||
| else | |||
| echo "la base de datos no es postgres: '$DATABASE'" | |||
| fi | |||
| @ -0,0 +1,16 @@ | |||
| """ | |||
| ASGI config for reymota 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.1/howto/deployment/asgi/ | |||
| """ | |||
| import os | |||
| from django.core.asgi import get_asgi_application | |||
| os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'reymota.settings') | |||
| application = get_asgi_application() | |||
| @ -0,0 +1,6 @@ | |||
| from django.conf import settings | |||
| def app_version(request): | |||
| return { | |||
| 'APP_VERSION': settings.APP_VERSION | |||
| } | |||
| @ -0,0 +1,209 @@ | |||
| """ | |||
| Django settings for entrenadores project. | |||
| Generated by 'django-admin startproject' using Django 5.1. | |||
| For more information on this file, see | |||
| https://docs.djangoproject.com/en/5.1/topics/settings/ | |||
| For the full list of settings and their values, see | |||
| https://docs.djangoproject.com/en/5.1/ref/settings/ | |||
| """ | |||
| from pathlib import Path | |||
| import os | |||
| import logging | |||
| APP_VERSION = "11.0.2" | |||
| # 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.1/howto/deployment/checklist/ | |||
| # SECURITY WARNING: keep the secret key used in production secret! | |||
| SECRET_KEY = 'django-insecure-vu#zk4g8pj-qoov#8^i$&s8n_ipp2r3h+o$z1w(1%d=6+i@erm' | |||
| # SECURITY WARNING: don't run with debug turned on in production! | |||
| # DEBUG = True | |||
| DEBUG = os.environ["DEBUG"] == 'True' | |||
| ALLOWED_HOSTS = [".ocp-cluster.reymota.lab", "reymota.es"] | |||
| # Application definition | |||
| INSTALLED_APPS = [ | |||
| 'django.contrib.admin', | |||
| 'django.contrib.auth', | |||
| 'django.contrib.contenttypes', | |||
| 'django.contrib.sessions', | |||
| 'django.contrib.messages', | |||
| 'django.contrib.staticfiles', | |||
| 'repostajes', | |||
| 'reymotausers', | |||
| 'lyrics', | |||
| ] | |||
| MIDDLEWARE = [ | |||
| 'django.middleware.security.SecurityMiddleware', | |||
| 'django.contrib.sessions.middleware.SessionMiddleware', | |||
| 'django.middleware.common.CommonMiddleware', | |||
| 'django.middleware.csrf.CsrfViewMiddleware', | |||
| 'django.contrib.auth.middleware.AuthenticationMiddleware', | |||
| 'django.contrib.messages.middleware.MessageMiddleware', | |||
| 'django.middleware.clickjacking.XFrameOptionsMiddleware', | |||
| ] | |||
| ROOT_URLCONF = 'entrenadores.urls' | |||
| TEMPLATES = [ | |||
| { | |||
| 'BACKEND': 'django.template.backends.django.DjangoTemplates', | |||
| 'DIRS': [BASE_DIR / 'templates'], | |||
| 'APP_DIRS': True, | |||
| 'OPTIONS': { | |||
| 'context_processors': [ | |||
| 'django.template.context_processors.debug', | |||
| 'django.template.context_processors.request', | |||
| 'django.contrib.auth.context_processors.auth', | |||
| 'django.contrib.messages.context_processors.messages', | |||
| 'entrenadores.context_processors.app_version', | |||
| ], | |||
| 'libraries': { | |||
| 'filtros_de_entorno': 'entrenadores.templatetags.filtros_de_entorno', | |||
| } | |||
| }, | |||
| }, | |||
| ] | |||
| WSGI_APPLICATION = 'entrenadores.wsgi.application' | |||
| # Database | |||
| # https://docs.djangoproject.com/en/5.0/ref/settings/#databases | |||
| DATABASES = { | |||
| "default": { | |||
| "ENGINE": "django.db.backends.postgresql", | |||
| "NAME": "entrenadores", | |||
| "USER": "creylopez", | |||
| "PASSWORD": "Dsa-0213", | |||
| "HOST": "postgresql", | |||
| "PORT": "5432", | |||
| } | |||
| } | |||
| # Password validation | |||
| # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators | |||
| 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 | |||
| # https://docs.djangoproject.com/en/5.1/topics/i18n/ | |||
| LANGUAGE_CODE = 'es-es' | |||
| TIME_ZONE = 'Europe/Madrid' | |||
| USE_I18N = True | |||
| USE_TZ = True | |||
| I18N = True | |||
| L10N = True | |||
| DECIMAL_SEPARATOR = ',' | |||
| THOUSAND_SEPARATOR = '.' | |||
| # Static files (CSS, JavaScript, Images) | |||
| # https://docs.djangoproject.com/en/5.1/howto/static-files/ | |||
| STATIC_URL = '/static/' | |||
| STATIC_ROOT = BASE_DIR / "staticfiles" | |||
| # Default primary key field type | |||
| # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field | |||
| DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' | |||
| LOGIN_URL = '/accounts/login/' | |||
| LOGIN_REDIRECT_URL = 'principal' | |||
| LOGOUT_REDIRECT_URL = 'principal' | |||
| AUTH_USER_MODEL = "reymotausers.ReyMotaUser" | |||
| MEDIA_ROOT = BASE_DIR / "mediafiles" | |||
| MEDIA_URL = '/media/' | |||
| CSRF_TRUSTED_ORIGINS = ["https://*.ocp-cluster.reymota.lab"] | |||
| LOGGING = { | |||
| 'version': 1, | |||
| 'disable_existing_loggers': False, | |||
| 'formatters': { | |||
| 'verbose': { | |||
| 'format': '{levelname} {asctime} {module} {message}', | |||
| 'style': '{', | |||
| }, | |||
| 'simple': { | |||
| 'format': '{levelname} {message}', | |||
| 'style': '{', | |||
| }, | |||
| }, | |||
| 'handlers': { | |||
| 'console': { | |||
| 'level': 'DEBUG', | |||
| 'class': 'logging.StreamHandler', | |||
| 'formatter': 'simple', | |||
| }, | |||
| 'file': { | |||
| 'level': 'DEBUG', | |||
| 'class': 'logging.FileHandler', | |||
| 'filename': '/dev/null', | |||
| 'formatter': 'verbose', | |||
| }, | |||
| 'mail_admins': { | |||
| 'level': 'ERROR', | |||
| 'class': 'django.utils.log.AdminEmailHandler', | |||
| }, | |||
| }, | |||
| 'loggers': { | |||
| 'django': { | |||
| 'handlers': ['file'], | |||
| 'level': 'DEBUG', | |||
| 'propagate': True, | |||
| }, | |||
| 'repostajes': { | |||
| 'handlers': ['console', 'file'], | |||
| 'level': 'DEBUG', | |||
| 'propagate': False, | |||
| }, | |||
| 'lyrics': { | |||
| 'handlers': ['console', 'file'], | |||
| 'level': 'DEBUG', | |||
| 'propagate': False, | |||
| }, | |||
| 'django.request': { | |||
| 'handlers': ['mail_admins'], | |||
| 'level': 'ERROR', | |||
| 'propagate': False, | |||
| }, | |||
| }, | |||
| } | |||
| @ -0,0 +1,9 @@ | |||
| import os | |||
| from django import template | |||
| register = template.Library() | |||
| @register.filter | |||
| def muestra_version(clave): | |||
| return os.getenv(clave, '') | |||
| @ -0,0 +1,43 @@ | |||
| """ | |||
| URL configuration for entrenadores project. | |||
| The `urlpatterns` list routes URLs to views. For more information please see: | |||
| https://docs.djangoproject.com/en/5.1/topics/http/urls/ | |||
| Examples: | |||
| Function views | |||
| 1. Add an import: from my_app import views | |||
| 2. Add a URL to urlpatterns: path('', views.home, name='home') | |||
| Class-based views | |||
| 1. Add an import: from other_app.views import Home | |||
| 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') | |||
| Including another URLconf | |||
| 1. Import the include() function: from django.urls import include, path | |||
| 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) | |||
| """ | |||
| from django.contrib import admin | |||
| from django.urls import path, include | |||
| from django.conf.urls.static import static | |||
| from django.conf import settings | |||
| from django.views.generic.base import TemplateView # new | |||
| from . import views | |||
| urlpatterns = [ | |||
| path('obreros/', admin.site.urls), | |||
| path('repostajes/', include('repostajes.urls')), | |||
| path('lyrics/', include('lyrics.urls')), | |||
| path("accounts/", include("accounts.urls")), # new | |||
| path("accounts/", include("django.contrib.auth.urls")), | |||
| path("", TemplateView.as_view(template_name="index.html"), | |||
| name="principal"), # new | |||
| path('entorno/', views.ver_variables_entorno, name='ver_variables_entorno'), | |||
| path('usuarios/', include("reymotausers.urls")), | |||
| ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) | |||
| @ -0,0 +1,31 @@ | |||
| import os | |||
| from django.conf import settings | |||
| from django.contrib.auth.decorators import user_passes_test | |||
| from django.shortcuts import render | |||
| from django.http import HttpResponseForbidden | |||
| @user_passes_test(lambda u: u.is_staff) | |||
| def ver_variables_entorno(request): | |||
| if not settings.DEBUG: | |||
| return HttpResponseForbidden("Acceso prohibido") | |||
| # Variables a excluir por motivos de seguridad | |||
| variables_excluidas = {'SECRET_KEY', 'DATABASES', 'EMAIL_HOST_PASSWORD', 'API_KEY'} | |||
| # Obtiene todas las variables de entorno | |||
| entorno = {key: os.getenv(key) for key in os.environ.keys() if key not in variables_excluidas} | |||
| # Obtiene todas las variables de settings excluyendo las confidenciales | |||
| configuracion = { | |||
| key: getattr(settings, key) for key in dir(settings) | |||
| if key.isupper() and key not in variables_excluidas | |||
| } | |||
| # Combina ambas en un solo diccionario | |||
| contexto = { | |||
| 'entorno': entorno, | |||
| 'configuracion': configuracion | |||
| } | |||
| return render(request, 'ver_entorno.html', contexto) | |||
| @ -0,0 +1,16 @@ | |||
| """ | |||
| WSGI config for reymota 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.1/howto/deployment/wsgi/ | |||
| """ | |||
| import os | |||
| from django.core.wsgi import get_wsgi_application | |||
| os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'entrenadores.settings') | |||
| application = get_wsgi_application() | |||
| @ -0,0 +1,9 @@ | |||
| from django.contrib import admin | |||
| # Register your models here. | |||
| from .models import Artista, Album, Song | |||
| admin.site.register(Artista) | |||
| admin.site.register(Album) | |||
| admin.site.register(Song) | |||
| @ -0,0 +1,6 @@ | |||
| from django.apps import AppConfig | |||
| class LyricsConfig(AppConfig): | |||
| default_auto_field = 'django.db.models.BigAutoField' | |||
| name = 'lyrics' | |||
| @ -0,0 +1,41 @@ | |||
| from django import forms | |||
| from .models import Artista, Album, Song | |||
| class ArtistaForm(forms.ModelForm): | |||
| class Meta: | |||
| model = Artista | |||
| fields = ['nombre', 'biografia', 'foto'] | |||
| nombre = forms.CharField( | |||
| widget=forms.TextInput(attrs={'class': 'form-control'})) | |||
| biografia = forms.CharField( | |||
| widget=forms.TextInput(attrs={'class': 'form-control'})) | |||
| class AlbumForm(forms.ModelForm): | |||
| class Meta: | |||
| model = Album | |||
| fields = ['name', 'artist', 'year', 'cover_image'] | |||
| # year = forms.DateField( | |||
| # widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'})) | |||
| artist = forms.ModelChoiceField( | |||
| queryset=Artista.objects.all(), | |||
| widget=forms.Select(attrs={'class': 'form-control'})) | |||
| class SongForm(forms.ModelForm): | |||
| class Meta: | |||
| model = Song | |||
| fields = ['title', 'artist', 'album', 'year', 'lyrics'] | |||
| artist = forms.ModelChoiceField( | |||
| queryset=Artista.objects.all(), | |||
| widget=forms.Select(attrs={'class': 'form-control'})) | |||
| album = forms.ModelChoiceField( | |||
| queryset=Album.objects.all(), # habría que seleccionar los álbumes del artista | |||
| widget=forms.Select(attrs={'class': 'form-control'})) | |||
| @ -0,0 +1,48 @@ | |||
| import json | |||
| from django.core.management.base import BaseCommand | |||
| from lyrics.models import Album, Artista | |||
| class Command(BaseCommand): | |||
| help = "Importa albumes desde un archivo JSON" | |||
| def add_arguments(self, parser): | |||
| parser.add_argument('archivo_json', type=str, help="Ruta del archivo JSON") | |||
| def handle(self, *args, **kwargs): | |||
| archivo_json = kwargs['archivo_json'] | |||
| try: | |||
| with open(archivo_json, 'r', encoding='utf-8') as file: | |||
| datos = json.load(file) | |||
| self.stdout.write(self.style.WARNING(f"\nSe encontraron {len(datos)} albumes en el archivo '{archivo_json}'.")) | |||
| confirmar = input("¿Deseas continuar con la importación? (s/n): ").strip().lower() | |||
| if confirmar != 's': | |||
| self.stdout.write(self.style.ERROR("Importación cancelada.")) | |||
| return | |||
| albumes_creados = 0 | |||
| for album_data in datos: | |||
| try: | |||
| artista = Artista.objects.get(nombre=album_data["artista_nombre"]) | |||
| creado = Album.objects.create( | |||
| artist=artista, | |||
| name=album_data['name'], | |||
| year=album_data['year'], | |||
| cover_image=album_data['cover_image'], | |||
| ) | |||
| if creado: | |||
| albumes_creados += 1 | |||
| except Artista.DoesNotExist: | |||
| self.stderr.write(self.style.ERROR(f"Artista '{album_data['artista_nombre']}' no encontrado.")) | |||
| self.stdout.write(self.style.SUCCESS(f'Se importaron {albumes_creados} albumes correctamente.')) | |||
| except FileNotFoundError: | |||
| self.stderr.write(self.style.ERROR(f"El archivo {archivo_json} no se encontró.")) | |||
| except json.JSONDecodeError: | |||
| self.stderr.write(self.style.ERROR("Error al leer el archivo JSON. Asegúrate de que el formato sea correcto.")) | |||
| @ -0,0 +1,42 @@ | |||
| import json | |||
| from django.core.management.base import BaseCommand | |||
| from lyrics.models import Artista | |||
| class Command(BaseCommand): | |||
| help = "Importa artistas desde un archivo JSON" | |||
| def add_arguments(self, parser): | |||
| parser.add_argument('archivo_json', type=str, help="Ruta del archivo JSON") | |||
| def handle(self, *args, **kwargs): | |||
| archivo_json = kwargs['archivo_json'] | |||
| try: | |||
| with open(archivo_json, 'r', encoding='utf-8') as file: | |||
| datos = json.load(file) | |||
| self.stdout.write(self.style.WARNING(f"\nSe encontraron {len(datos)} artistas en el archivo '{archivo_json}'.")) | |||
| confirmar = input("¿Deseas continuar con la importación? (s/n): ").strip().lower() | |||
| if confirmar != 's': | |||
| self.stdout.write(self.style.ERROR("Importación cancelada.")) | |||
| return | |||
| artistas_creados = 0 | |||
| for artista_data in datos: | |||
| creado = Artista.objects.create( | |||
| id=artista_data['id'], | |||
| nombre=artista_data['nombre'], | |||
| biografia=artista_data['biografia'], | |||
| foto=artista_data['foto'] | |||
| ) | |||
| if creado: | |||
| artistas_creados += 1 | |||
| self.stdout.write(self.style.SUCCESS(f'Se importaron {artistas_creados} artistas correctamente.')) | |||
| except FileNotFoundError: | |||
| self.stderr.write(self.style.ERROR(f"El archivo {archivo_json} no se encontró.")) | |||
| except json.JSONDecodeError: | |||
| self.stderr.write(self.style.ERROR("Error al leer el archivo JSON. Asegúrate de que el formato sea correcto.")) | |||
| @ -0,0 +1,51 @@ | |||
| import json | |||
| from django.core.management.base import BaseCommand | |||
| from lyrics.models import Song, Album, Artista | |||
| class Command(BaseCommand): | |||
| help = "Importa canciones desde un archivo JSON" | |||
| def add_arguments(self, parser): | |||
| parser.add_argument('archivo_json', type=str, help="Ruta del archivo JSON") | |||
| def handle(self, *args, **kwargs): | |||
| archivo_json = kwargs['archivo_json'] | |||
| try: | |||
| with open(archivo_json, 'r', encoding='utf-8') as file: | |||
| datos = json.load(file) | |||
| self.stdout.write(self.style.WARNING(f"\nSe encontraron {len(datos)} canciones en el archivo '{archivo_json}'.")) | |||
| confirmar = input("¿Deseas continuar con la importación? (s/n): ").strip().lower() | |||
| if confirmar != 's': | |||
| self.stdout.write(self.style.ERROR("Importación cancelada.")) | |||
| return | |||
| canciones_creados = 0 | |||
| for cancion_data in datos: | |||
| try: | |||
| album = Album.objects.get(name=cancion_data["album_nombre"]) | |||
| artista = Artista.objects.get(nombre=cancion_data["artista_nombre"]) | |||
| creado = Song.objects.create( | |||
| album=album, | |||
| artist=artista, | |||
| title=cancion_data['title'], | |||
| year=cancion_data['year'], | |||
| lyrics=cancion_data['lyrics'], | |||
| pista=cancion_data['pista'], | |||
| ) | |||
| if creado: | |||
| canciones_creados += 1 | |||
| except Album.DoesNotExist: | |||
| self.stderr.write(self.style.ERROR(f"Album '{cancion_data['album']}' no encontrado.")) | |||
| self.stdout.write(self.style.SUCCESS(f'Se importaron {canciones_creados} canciones correctamente.')) | |||
| except FileNotFoundError: | |||
| self.stderr.write(self.style.ERROR(f"El archivo {archivo_json} no se encontró.")) | |||
| except json.JSONDecodeError: | |||
| self.stderr.write(self.style.ERROR("Error al leer el archivo JSON. Asegúrate de que el formato sea correcto.")) | |||
| @ -0,0 +1,42 @@ | |||
| from django.db import models | |||
| import datetime | |||
| from django.core.validators import MaxValueValidator, MinValueValidator | |||
| def current_year(): | |||
| return datetime.date.today().year | |||
| def max_value_current_year(value): | |||
| return MaxValueValidator(current_year())(value) | |||
| class Artista(models.Model): | |||
| nombre = models.CharField(max_length=200) | |||
| biografia = models.TextField(blank=True, null=True) | |||
| foto = models.ImageField(upload_to='artistas/', blank=True, null=True) # Nuevo campo | |||
| def __str__(self): | |||
| return self.nombre | |||
| class Album(models.Model): | |||
| name = models.CharField(max_length=200) | |||
| artist = models.ForeignKey(Artista, on_delete=models.CASCADE) | |||
| year = models.PositiveBigIntegerField(default=current_year(), validators=[MinValueValidator(1945), max_value_current_year]) | |||
| cover_image = models.ImageField(upload_to='cover_image/', blank=True, null=True) # Nuevo campo | |||
| def __str__(self): | |||
| return self.name | |||
| class Song(models.Model): | |||
| title = models.CharField(max_length=200) | |||
| artist = models.ForeignKey(Artista, on_delete=models.CASCADE) | |||
| album = models.ForeignKey(Album, on_delete=models.CASCADE, related_name='song') | |||
| year = models.DecimalField(max_digits=4, decimal_places=0, blank=False, null=False) | |||
| lyrics = models.TextField() | |||
| pista = models.DecimalField(max_digits=5, decimal_places=0, blank=True, null=True) | |||
| def __str__(self): | |||
| return self.title | |||
| @ -0,0 +1,39 @@ | |||
| import os | |||
| from rest_framework import serializers | |||
| from .models import Artista, Album, Song | |||
| class ArtistaSerializer(serializers.ModelSerializer): | |||
| class Meta: | |||
| model = Artista | |||
| fields = '__all__' # Incluir todos los campos del modelo | |||
| def to_representation(self, instance): | |||
| ret = super().to_representation(instance) | |||
| ret['foto'] = "artistas/" + os.path.basename(ret['foto']) | |||
| return ret | |||
| class AlbumSerializer(serializers.ModelSerializer): | |||
| artista_nombre = serializers.CharField(source='artist.nombre', read_only=True) | |||
| class Meta: | |||
| model = Album | |||
| fields = ['name', 'year', 'cover_image', 'artista_nombre'] | |||
| def to_representation(self, instance): | |||
| ret = super().to_representation(instance) | |||
| ret['cover_image'] = "cover_image/" + os.path.basename(ret['cover_image']) | |||
| return ret | |||
| class CancionSerializer(serializers.ModelSerializer): | |||
| artista_nombre = serializers.CharField(source='artist.nombre', read_only=True) | |||
| album_nombre = serializers.CharField(source='album.name', read_only=True) | |||
| class Meta: | |||
| model = Song | |||
| fields = ['title', 'year', 'lyrics', 'pista', 'artista_nombre', 'album_nombre'] | |||
| @ -0,0 +1,3 @@ | |||
| from django.test import TestCase | |||
| # Create your tests here. | |||
| @ -0,0 +1,39 @@ | |||
| from django.urls import path | |||
| from . import views | |||
| from .views import api_lista_artistas, api_detalle_artista | |||
| from .views import api_lista_albumes, api_detalle_album | |||
| from .views import api_lista_canciones, api_detalle_cancion | |||
| app_name = 'lyrics' | |||
| urlpatterns = [ | |||
| path('', views.principal, name='principal'), | |||
| path('artistas/', views.lista_artistas, name='lista_artistas'), | |||
| path('artistas/nuevo/', views.nuevo_artista, name='nuevo_artista'), | |||
| path('artistas/<int:artista_id>/', views.detalle_artista, name='detalle_artista'), | |||
| path('artistas/<int:artista_id>/editar/', views.editar_artista, name='editar_artista'), | |||
| path('artistas/<int:artista_id>/eliminar/', views.eliminar_artista, name='eliminar_artista'), | |||
| path('album/', views.lista_albumes, name='lista_albumes'), | |||
| path('album/nuevo/', views.nuevo_album, name='nuevo_album'), | |||
| path('album/<int:album_id>/', views.detalle_album, name='detalle_album'), | |||
| path('album/<int:album_id>/editar/', views.editar_album, name='editar_album'), | |||
| path('album/<int:album_id>/eliminar/', views.eliminar_album, name='eliminar_album'), | |||
| path('song/', views.lista_songs, name='lista_songs'), | |||
| path('song/nuevo/', views.nuevo_song, name='nuevo_song'), | |||
| path('song/<int:song_id>/', views.detalle_song, name='detalle_song'), | |||
| path('song/<int:song_id>/editar/', views.editar_song, name='editar_song'), | |||
| path('song/<int:song_id>/eliminar/', views.eliminar_song, name='eliminar_song'), | |||
| path('api/artistas/', api_lista_artistas, name='api_lista_artistas'), | |||
| path('api/artistas/<int:artista_id>/', api_detalle_artista, name='api_detalle_artista'), | |||
| path('api/albumes/', api_lista_albumes, name='api_lista_albumes'), | |||
| path('api/albumes/<int:album_id>/', api_detalle_album, name='api_detalle_album'), | |||
| path('api/canciones/', api_lista_canciones, name='api_lista_canciones'), | |||
| path('api/canciones/<int:cancion_id>/', api_detalle_cancion, name='api_detalle_cancion'), | |||
| ] | |||
| @ -0,0 +1,251 @@ | |||
| # Create your views here. | |||
| from django.shortcuts import render, get_object_or_404, redirect | |||
| from django.contrib.auth.decorators import login_required | |||
| from rest_framework.response import Response | |||
| from rest_framework.decorators import api_view | |||
| from .models import Artista, Album, Song | |||
| from .forms import ArtistaForm, AlbumForm, SongForm | |||
| from .serializers import ArtistaSerializer, AlbumSerializer, CancionSerializer | |||
| import logging | |||
| logger = logging.getLogger(__name__) | |||
| @login_required | |||
| def principal(request): | |||
| artistas = Artista.objects.all() | |||
| albumes = Album.objects.all() | |||
| return render(request, 'lyrics/index.html', {'artistas': artistas, 'albumes': albumes}) | |||
| ######################### | |||
| # Vistas para los artistas | |||
| @login_required | |||
| def lista_artistas(request): | |||
| artistas = Artista.objects.all() | |||
| return render(request, 'lyrics/lista_artistas.html', {'artistas': artistas}) | |||
| @login_required | |||
| def detalle_artista(request, artista_id): | |||
| artista = get_object_or_404(Artista, pk=artista_id) | |||
| albumes = Album.objects.filter(artist=artista_id) | |||
| return render(request, 'lyrics/detalle_artista.html', {'artista': artista, 'albumes': albumes}) | |||
| @login_required | |||
| def nuevo_artista(request): | |||
| if request.method == 'POST': | |||
| form = ArtistaForm(request.POST, request.FILES) | |||
| if form.is_valid(): | |||
| form.save() | |||
| return redirect('lyrics:lista_artistas') | |||
| else: | |||
| form = ArtistaForm() | |||
| return render(request, 'lyrics/form_artista.html', {'form': form}) | |||
| @login_required | |||
| def editar_artista(request, artista_id): | |||
| artista = get_object_or_404(Artista, pk=artista_id) | |||
| if request.method == 'POST': | |||
| form = ArtistaForm(request.POST, request.FILES, instance=artista) | |||
| if form.is_valid(): | |||
| form.save() | |||
| return redirect('lyrics:lista_artistas') | |||
| else: | |||
| form = ArtistaForm(instance=artista) | |||
| return render(request, 'lyrics/form_artista.html', {'form': form}) | |||
| @login_required | |||
| def eliminar_artista(request, artista_id): | |||
| artista = get_object_or_404(Artista, pk=artista_id) | |||
| artista.delete() | |||
| return redirect('lyrics:lista_artistas') | |||
| ######################### | |||
| # Vistas para los albumes | |||
| @login_required | |||
| def lista_albumes(request): | |||
| albumes = Album.objects.all() | |||
| return render(request, 'lyrics/lista_albumes.html', {'albumes': albumes}) | |||
| @login_required | |||
| def detalle_album(request, album_id): | |||
| album = get_object_or_404(Album, pk=album_id) | |||
| songs = Song.objects.filter(album_id=album_id) | |||
| return render(request, 'lyrics/detalle_album.html', {'album': album, 'songs': songs}) | |||
| @login_required | |||
| def nuevo_album(request): | |||
| artistas = Artista.objects.all() # vamos a ver si hay vehículos dados de alta | |||
| if artistas: | |||
| if request.method == 'POST': | |||
| form = AlbumForm(request.POST, request.FILES) | |||
| if form.is_valid(): | |||
| form.save() | |||
| return redirect('lyrics:lista_albumes') | |||
| else: | |||
| form = AlbumForm() | |||
| return render(request, 'lyrics/form_album.html', {'form': form}) | |||
| else: | |||
| return render(request, 'lyrics/index.html') | |||
| @login_required | |||
| def editar_album(request, album_id): | |||
| album = get_object_or_404(Album, pk=album_id) | |||
| if request.method == 'POST': | |||
| form = AlbumForm(request.POST, request.FILES, instance=album) | |||
| if form.is_valid(): | |||
| form.save() | |||
| return redirect('lyrics:lista_albumes') | |||
| else: | |||
| form = AlbumForm(instance=album) | |||
| return render(request, 'lyrics/form_album.html', {'form': form}) | |||
| @login_required | |||
| def eliminar_album(request, album_id): | |||
| album = Album.objects.get(pk=album_id) | |||
| album.delete() | |||
| return redirect('lyrics:lista_albumes') | |||
| ######################### | |||
| # Vistas para los songs | |||
| @login_required | |||
| def lista_songs(request): | |||
| songs = Song.objects.all() | |||
| return render(request, 'lyrics/lista_songs.html', {'songs': songs}) | |||
| @login_required | |||
| def detalle_song(request, song_id): | |||
| song = get_object_or_404(Song, pk=song_id) | |||
| albumes = Album.objects.filter(song=song_id) | |||
| return render(request, 'lyrics/detalle_song.html', {'song': song, 'albumes': albumes}) | |||
| @login_required | |||
| def nuevo_song(request): | |||
| album_id = request.GET.get('album_id') # Obtener el album_id de los parámetros de la URL | |||
| if request.method == 'POST': | |||
| form = SongForm(request.POST, request.FILES) | |||
| if form.is_valid(): | |||
| album = form.cleaned_data['album'] | |||
| song_count = album.song.count() | |||
| nueva_cancion = form.save(commit=False) | |||
| nueva_cancion.pista = song_count + 1 | |||
| nueva_cancion.save() | |||
| logger.info("Canción creada %s", nueva_cancion.title) | |||
| return redirect('lyrics:lista_songs') | |||
| else: | |||
| if album_id: | |||
| # Si tenemos un album_id, preseleccionamos ese álbum en el formulario | |||
| album = get_object_or_404(Album, id=album_id) | |||
| form = SongForm(initial={'album': album, 'artist': album.artist, 'year': album.year}) | |||
| else: | |||
| form = SongForm() | |||
| return render(request, 'lyrics/form_song.html', {'form': form}) | |||
| @login_required | |||
| def editar_song(request, song_id): | |||
| song = get_object_or_404(Song, pk=song_id) | |||
| if request.method == 'POST': | |||
| form = SongForm(request.POST, request.FILES, instance=song) | |||
| if form.is_valid(): | |||
| form.save() | |||
| return redirect('lyrics:lista_songs') | |||
| else: | |||
| form = SongForm(instance=song) | |||
| return render(request, 'lyrics/form_song.html', {'form': form}) | |||
| @login_required | |||
| def eliminar_song(request, song_id): | |||
| song = get_object_or_404(Song, pk=song_id) | |||
| song.delete() | |||
| return redirect('lyrics:lista_songs') | |||
| @api_view(['GET']) | |||
| def api_lista_artistas(request): | |||
| """Devuelve la lista de todos los artistas.""" | |||
| artistas = Artista.objects.all() | |||
| serializer = ArtistaSerializer(artistas, many=True) | |||
| return Response(serializer.data) | |||
| @api_view(['GET']) | |||
| def api_detalle_artista(request, artista_id): | |||
| """Devuelve los detalles de un artista específico.""" | |||
| try: | |||
| artista = Artista.objects.get(id=artista_id) | |||
| serializer = ArtistaSerializer(artista) | |||
| return Response(serializer.data) | |||
| except Artista.DoesNotExist: | |||
| return Response({'error': 'Artista no encontrado'}, status=404) | |||
| @api_view(['GET']) | |||
| def api_lista_albumes(request): | |||
| """Devuelve la lista de todos los albumes.""" | |||
| albumes = Album.objects.all() | |||
| serializer = AlbumSerializer(albumes, many=True) | |||
| return Response(serializer.data) | |||
| @api_view(['GET']) | |||
| def api_detalle_album(request, album_id): | |||
| """Devuelve los detalles de un album específico.""" | |||
| try: | |||
| album = Album.objects.get(id=album_id) | |||
| serializer = AlbumSerializer(album) | |||
| return Response(serializer.data) | |||
| except Album.DoesNotExist: | |||
| return Response({'error': 'Album no encontrado'}, status=404) | |||
| @api_view(['GET']) | |||
| def api_lista_canciones(request): | |||
| """Devuelve la lista de todos los canciones.""" | |||
| canciones = Song.objects.all() | |||
| serializer = CancionSerializer(canciones, many=True) | |||
| return Response(serializer.data) | |||
| @api_view(['GET']) | |||
| def api_detalle_cancion(request, cancion_id): | |||
| """Devuelve los detalles de un cancion específica.""" | |||
| try: | |||
| cancion = Song.objects.get(id=cancion_id) | |||
| serializer = CancionSerializer(cancion) | |||
| return Response(serializer.data) | |||
| except Song.DoesNotExist: | |||
| return Response({'error': 'Canción no encontrada'}, status=404) | |||
| @ -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', 'entrenadores.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() | |||
| @ -0,0 +1 @@ | |||
| migrations/ | |||
| @ -0,0 +1,9 @@ | |||
| from django.contrib import admin | |||
| # Register your models here. | |||
| from .models import Vehiculo, Repostaje | |||
| admin.site.register(Vehiculo) | |||
| admin.site.register(Repostaje) | |||
| @ -0,0 +1,6 @@ | |||
| from django.apps import AppConfig | |||
| class RepostajesConfig(AppConfig): | |||
| default_auto_field = 'django.db.models.BigAutoField' | |||
| name = 'repostajes' | |||
| @ -0,0 +1,41 @@ | |||
| from django import forms | |||
| from .models import Vehiculo, Repostaje | |||
| class VehiculoForm(forms.ModelForm): | |||
| class Meta: | |||
| model = Vehiculo | |||
| fields = ['marca', 'modelo', 'matricula', 'foto'] | |||
| marca = forms.CharField( | |||
| widget=forms.TextInput(attrs={'class': 'form-control'})) | |||
| modelo = forms.CharField( | |||
| widget=forms.TextInput(attrs={'class': 'form-control'})) | |||
| matricula = forms.CharField( | |||
| widget=forms.TextInput(attrs={'class': 'form-control'})) | |||
| class RepostajeForm(forms.ModelForm): | |||
| class Meta: | |||
| model = Repostaje | |||
| fields = ['fecha', 'vehiculo', 'kms', 'litros', 'importe'] | |||
| exclude = ['descuento', 'precioxlitro'] | |||
| fecha = forms.DateField( | |||
| widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'})) | |||
| vehiculo = forms.ModelChoiceField( | |||
| queryset=Vehiculo.objects.all(), | |||
| widget=forms.Select(attrs={'class': 'form-control'})) | |||
| kms = forms.DecimalField( | |||
| widget=forms.TextInput(attrs={'class': 'form-control'})) | |||
| litros = forms.DecimalField( | |||
| widget=forms.NumberInput(attrs={'class': 'form-control'})) | |||
| importe = forms.DecimalField( | |||
| widget=forms.NumberInput(attrs={'class': 'form-control'})) | |||
| aplica_descuento = forms.BooleanField(initial=False, required=False) | |||
| @ -0,0 +1,53 @@ | |||
| import json | |||
| from django.core.management.base import BaseCommand | |||
| from repostajes.models import Repostaje, Vehiculo | |||
| class Command(BaseCommand): | |||
| help = "Importa repostajes desde un archivo JSON" | |||
| def add_arguments(self, parser): | |||
| parser.add_argument('archivo_json', type=str, help="Ruta del archivo JSON") | |||
| def handle(self, *args, **kwargs): | |||
| archivo_json = kwargs['archivo_json'] | |||
| try: | |||
| with open(archivo_json, 'r', encoding='utf-8') as file: | |||
| datos = json.load(file) | |||
| self.stdout.write(self.style.WARNING(f"\nSe encontraron {len(datos)} repostajes en el archivo '{archivo_json}'.")) | |||
| confirmar = input("¿Deseas continuar con la importación? (s/n): ").strip().lower() | |||
| if confirmar != 's': | |||
| self.stdout.write(self.style.ERROR("Importación cancelada.")) | |||
| return | |||
| repostajes_creados = 0 | |||
| for repostaje_data in datos: | |||
| try: | |||
| vehiculo = Vehiculo.objects.get(matricula=repostaje_data["vehiculo_matricula"]) | |||
| creado = Repostaje.objects.create( | |||
| vehiculo=vehiculo, | |||
| fecha=repostaje_data['fecha'], | |||
| kms=repostaje_data['kms'], | |||
| litros=repostaje_data['litros'], | |||
| descuento=repostaje_data['descuento'], | |||
| importe=repostaje_data['importe'], | |||
| precioxlitro=repostaje_data['precioxlitro'], | |||
| kmsrecorridos=repostaje_data['kmsrecorridos'], | |||
| consumo=repostaje_data['consumo'] | |||
| ) | |||
| if creado: | |||
| repostajes_creados += 1 | |||
| except Vehiculo.DoesNotExist: | |||
| self.stderr.write(self.style.ERROR(f"Vehiculo con matrícula '{repostaje_data['vehiculo_matricula']}' no encontrado.")) | |||
| self.stdout.write(self.style.SUCCESS(f'Se importaron {repostajes_creados} repostajes correctamente.')) | |||
| except FileNotFoundError: | |||
| self.stderr.write(self.style.ERROR(f"El archivo {archivo_json} no se encontró.")) | |||
| except json.JSONDecodeError: | |||
| self.stderr.write(self.style.ERROR("Error al leer el archivo JSON. Asegúrate de que el formato sea correcto.")) | |||
| @ -0,0 +1,42 @@ | |||
| import json | |||
| from django.core.management.base import BaseCommand | |||
| from repostajes.models import Vehiculo | |||
| class Command(BaseCommand): | |||
| help = "Importa vehiculos desde un archivo JSON" | |||
| def add_arguments(self, parser): | |||
| parser.add_argument('archivo_json', type=str, help="Ruta del archivo JSON") | |||
| def handle(self, *args, **kwargs): | |||
| archivo_json = kwargs['archivo_json'] | |||
| try: | |||
| with open(archivo_json, 'r', encoding='utf-8') as file: | |||
| datos = json.load(file) | |||
| self.stdout.write(self.style.WARNING(f"\nSe encontraron {len(datos)} vehiculos en el archivo '{archivo_json}'.")) | |||
| confirmar = input("¿Deseas continuar con la importación? (s/n): ").strip().lower() | |||
| if confirmar != 's': | |||
| self.stdout.write(self.style.ERROR("Importación cancelada.")) | |||
| return | |||
| vehiculos_creados = 0 | |||
| for vehiculo_data in datos: | |||
| creado = Vehiculo.objects.create( | |||
| marca=vehiculo_data['marca'], | |||
| modelo=vehiculo_data['modelo'], | |||
| matricula=vehiculo_data['matricula'], | |||
| foto=vehiculo_data['foto'] | |||
| ) | |||
| if creado: | |||
| vehiculos_creados += 1 | |||
| self.stdout.write(self.style.SUCCESS(f'Se importaron {vehiculos_creados} vehiculos correctamente.')) | |||
| except FileNotFoundError: | |||
| self.stderr.write(self.style.ERROR(f"El archivo {archivo_json} no se encontró.")) | |||
| except json.JSONDecodeError: | |||
| self.stderr.write(self.style.ERROR("Error al leer el archivo JSON. Asegúrate de que el formato sea correcto.")) | |||
| @ -0,0 +1,27 @@ | |||
| from django.db import models | |||
| from django.core.validators import MaxValueValidator | |||
| class Vehiculo(models.Model): | |||
| marca = models.CharField(max_length=200) | |||
| modelo = models.CharField(max_length=200) | |||
| matricula = models.CharField(max_length=200) | |||
| foto = models.ImageField(upload_to='vehiculos/', blank=True, null=True) # Nuevo campo | |||
| def __str__(self): | |||
| return self.marca | |||
| class Repostaje(models.Model): | |||
| vehiculo = models.ForeignKey(Vehiculo, on_delete=models.CASCADE) | |||
| fecha = models.DateField() | |||
| kms = models.DecimalField(max_digits=10, decimal_places=0, blank=True, null=True) | |||
| litros = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) | |||
| descuento = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) | |||
| importe = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) | |||
| precioxlitro = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) | |||
| kmsrecorridos = models.DecimalField(max_digits=10, decimal_places=0, blank=True, null=True) | |||
| consumo = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) | |||
| def __str__(self): | |||
| return str(self.fecha) | |||
| @ -0,0 +1,16 @@ | |||
| from rest_framework import serializers | |||
| from .models import Vehiculo, Repostaje | |||
| class VehiculoSerializer(serializers.ModelSerializer): | |||
| class Meta: | |||
| model = Vehiculo | |||
| fields = '__all__' # Incluir todos los campos del modelo | |||
| class RepostajeSerializer(serializers.ModelSerializer): | |||
| vehiculo_matricula = serializers.CharField(source='vehiculo.matricula', read_only=True) | |||
| class Meta: | |||
| model = Repostaje | |||
| fields = ['id', 'fecha', 'kms', 'litros', 'descuento', 'importe', 'precioxlitro', 'kmsrecorridos', 'consumo', 'vehiculo', 'vehiculo_matricula'] | |||
| @ -0,0 +1,21 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <svg width="215px" height="215px" viewBox="0 0 215 215" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |||
| <!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch --> | |||
| <title>portal-logo</title> | |||
| <desc>Created with Sketch.</desc> | |||
| <defs> | |||
| <path d="M51.165,8.742 C54.505,12.619 56.876,17.365 57.892,22.588 C60.148,17.225 65.452,13.46 71.636,13.46 C79.867,13.46 86.541,20.134 86.541,28.365 C86.541,36.597 79.867,43.269 71.636,43.269 C63.404,43.269 56.728,36.597 56.728,28.365 C56.728,12.7 44.03,0 28.365,0 C12.7,0 0,12.7 0,28.365 C0,44.031 12.7,56.731 28.365,56.731 C36.419,56.731 43.695,53.393 48.858,48.003 C45.501,44.117 43.128,39.383 42.108,34.14 C39.852,39.504 34.548,43.269 28.365,43.269 C20.133,43.269 13.46,36.597 13.46,28.365 C13.46,20.134 20.133,13.46 28.365,13.46 C36.966,13.46 43.27,20.577 43.27,28.365 C43.27,44.031 55.97,56.731 71.636,56.731 C87.3,56.731 100,44.031 100,28.365 C100,12.7 87.3,0 71.636,0 C63.589,0 56.327,3.358 51.165,8.742 Z" id="path-1"></path> | |||
| </defs> | |||
| <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> | |||
| <g id="portal-logo"> | |||
| <circle id="Oval" fill="#51B37F" fill-rule="nonzero" cx="107.5" cy="107.5" r="107.5"></circle> | |||
| <g id="logo" transform="translate(58.000000, 79.000000)"> | |||
| <mask id="mask-2" fill="white"> | |||
| <use xlink:href="#path-1"></use> | |||
| </mask> | |||
| <g id="Clip-2"></g> | |||
| <polygon id="Fill-1" fill="#FFFFFE" mask="url(#mask-2)" points="-5 61.73 105 61.73 105 -5 -5 -5"></polygon> | |||
| </g> | |||
| </g> | |||
| </g> | |||
| </svg> | |||
| @ -0,0 +1,21 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <svg width="215px" height="215px" viewBox="0 0 215 215" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |||
| <!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch --> | |||
| <title>portal-logo</title> | |||
| <desc>Created with Sketch.</desc> | |||
| <defs> | |||
| <path d="M51.165,8.742 C54.505,12.619 56.876,17.365 57.892,22.588 C60.148,17.225 65.452,13.46 71.636,13.46 C79.867,13.46 86.541,20.134 86.541,28.365 C86.541,36.597 79.867,43.269 71.636,43.269 C63.404,43.269 56.728,36.597 56.728,28.365 C56.728,12.7 44.03,0 28.365,0 C12.7,0 0,12.7 0,28.365 C0,44.031 12.7,56.731 28.365,56.731 C36.419,56.731 43.695,53.393 48.858,48.003 C45.501,44.117 43.128,39.383 42.108,34.14 C39.852,39.504 34.548,43.269 28.365,43.269 C20.133,43.269 13.46,36.597 13.46,28.365 C13.46,20.134 20.133,13.46 28.365,13.46 C36.966,13.46 43.27,20.577 43.27,28.365 C43.27,44.031 55.97,56.731 71.636,56.731 C87.3,56.731 100,44.031 100,28.365 C100,12.7 87.3,0 71.636,0 C63.589,0 56.327,3.358 51.165,8.742 Z" id="path-1"></path> | |||
| </defs> | |||
| <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> | |||
| <g id="portal-logo"> | |||
| <circle id="Oval" fill="#09B6CA" fill-rule="nonzero" cx="107.5" cy="107.5" r="107.5"></circle> | |||
| <g id="logo" transform="translate(58.000000, 79.000000)"> | |||
| <mask id="mask-2" fill="white"> | |||
| <use xlink:href="#path-1"></use> | |||
| </mask> | |||
| <g id="Clip-2"></g> | |||
| <polygon id="Fill-1" fill="#FFFFFE" mask="url(#mask-2)" points="-5 61.73 105 61.73 105 -5 -5 -5"></polygon> | |||
| </g> | |||
| </g> | |||
| </g> | |||
| </svg> | |||
| @ -0,0 +1,18 @@ | |||
| <svg width="400" height="400" xmlns="http://www.w3.org/2000/svg"> | |||
| <!-- Fondo --> | |||
| <rect width="400" height="400" fill="#ffffff" /> | |||
| <!-- Corona --> | |||
| <g transform="translate(100, 100) scale(2)"> | |||
| <polygon points="50,150 75,50 100,150" fill="#FFD700" /> | |||
| <polygon points="0,150 50,0 100,150" fill="#FFD700" /> | |||
| <polygon points="100,150 125,50 150,150" fill="#FFD700" /> | |||
| </g> | |||
| <!-- Letra R --> | |||
| <!-- | |||
| <text x="100" y="360" font-family="Arial, sans-serif" font-size="400" fill="#000000" font-weight="bold">R</text> | |||
| --> | |||
| <text x="100" y="360" font-family="Open Sans" font-size="400" fill="#000000" font-weight="bold">R</text> | |||
| </svg> | |||
| @ -0,0 +1,96 @@ | |||
| 'use strict'; | |||
| /* ===== Enable Bootstrap Popover (on element ====== */ | |||
| const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]') | |||
| const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl)) | |||
| /* ==== Enable Bootstrap Alert ====== */ | |||
| //var alertList = document.querySelectorAll('.alert') | |||
| //alertList.forEach(function (alert) { | |||
| // new bootstrap.Alert(alert) | |||
| //}); | |||
| const alertList = document.querySelectorAll('.alert') | |||
| const alerts = [...alertList].map(element => new bootstrap.Alert(element)) | |||
| /* ===== Responsive Sidepanel ====== */ | |||
| const sidePanelToggler = document.getElementById('sidepanel-toggler'); | |||
| const sidePanel = document.getElementById('app-sidepanel'); | |||
| const sidePanelDrop = document.getElementById('sidepanel-drop'); | |||
| const sidePanelClose = document.getElementById('sidepanel-close'); | |||
| window.addEventListener('load', function(){ | |||
| responsiveSidePanel(); | |||
| }); | |||
| window.addEventListener('resize', function(){ | |||
| responsiveSidePanel(); | |||
| }); | |||
| function responsiveSidePanel() { | |||
| let w = window.innerWidth; | |||
| if(w >= 1200) { | |||
| // if larger | |||
| //console.log('larger'); | |||
| sidePanel.classList.remove('sidepanel-hidden'); | |||
| sidePanel.classList.add('sidepanel-visible'); | |||
| } else { | |||
| // if smaller | |||
| //console.log('smaller'); | |||
| sidePanel.classList.remove('sidepanel-visible'); | |||
| sidePanel.classList.add('sidepanel-hidden'); | |||
| } | |||
| }; | |||
| sidePanelToggler.addEventListener('click', () => { | |||
| if (sidePanel.classList.contains('sidepanel-visible')) { | |||
| console.log('visible'); | |||
| sidePanel.classList.remove('sidepanel-visible'); | |||
| sidePanel.classList.add('sidepanel-hidden'); | |||
| } else { | |||
| console.log('hidden'); | |||
| sidePanel.classList.remove('sidepanel-hidden'); | |||
| sidePanel.classList.add('sidepanel-visible'); | |||
| } | |||
| }); | |||
| sidePanelClose.addEventListener('click', (e) => { | |||
| e.preventDefault(); | |||
| sidePanelToggler.click(); | |||
| }); | |||
| sidePanelDrop.addEventListener('click', (e) => { | |||
| sidePanelToggler.click(); | |||
| }); | |||
| /* ====== Mobile search ======= */ | |||
| const searchMobileTrigger = document.querySelector('.search-mobile-trigger'); | |||
| const searchBox = document.querySelector('.app-search-box'); | |||
| searchMobileTrigger.addEventListener('click', () => { | |||
| searchBox.classList.toggle('is-visible'); | |||
| let searchMobileTriggerIcon = document.querySelector('.search-mobile-trigger-icon'); | |||
| if(searchMobileTriggerIcon.classList.contains('fa-magnifying-glass')) { | |||
| searchMobileTriggerIcon.classList.remove('fa-magnifying-glass'); | |||
| searchMobileTriggerIcon.classList.add('fa-xmark'); | |||
| } else { | |||
| searchMobileTriggerIcon.classList.remove('fa-xmark'); | |||
| searchMobileTriggerIcon.classList.add('fa-magnifying-glass'); | |||
| } | |||
| }); | |||
| @ -0,0 +1,366 @@ | |||
| 'use strict'; | |||
| /* Chart.js docs: https://www.chartjs.org/ */ | |||
| window.chartColors = { | |||
| green: '#75c181', // rgba(117,193,129, 1) | |||
| blue: '#5b99ea', // rgba(91,153,234, 1) | |||
| gray: '#a9b5c9', | |||
| text: '#252930', | |||
| border: '#e7e9ed' | |||
| }; | |||
| /* Random number generator for demo purpose */ | |||
| var randomDataPoint = function(){ return Math.round(Math.random()*100)}; | |||
| //Area line Chart Demo | |||
| var lineChartConfig = { | |||
| type: 'line', | |||
| data: { | |||
| labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'], | |||
| datasets: [{ | |||
| label: 'Dataset', | |||
| backgroundColor: "rgba(117,193,129,0.2)", | |||
| borderColor: "rgba(117,193,129, 0.8)", | |||
| data: [ | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint() | |||
| ], | |||
| }] | |||
| }, | |||
| options: { | |||
| responsive: true, | |||
| legend: { | |||
| display: true, | |||
| position: 'bottom', | |||
| align: 'end', | |||
| }, | |||
| tooltips: { | |||
| mode: 'index', | |||
| intersect: false, | |||
| titleMarginBottom: 10, | |||
| bodySpacing: 10, | |||
| xPadding: 16, | |||
| yPadding: 16, | |||
| borderColor: window.chartColors.border, | |||
| borderWidth: 1, | |||
| backgroundColor: '#fff', | |||
| bodyFontColor: window.chartColors.text, | |||
| titleFontColor: window.chartColors.text, | |||
| callbacks: { | |||
| label: function(tooltipItem, data) { | |||
| return tooltipItem.value + '%'; | |||
| } | |||
| }, | |||
| }, | |||
| hover: { | |||
| mode: 'nearest', | |||
| intersect: true | |||
| }, | |||
| scales: { | |||
| xAxes: [{ | |||
| display: true, | |||
| gridLines: { | |||
| drawBorder: false, | |||
| color: window.chartColors.border, | |||
| }, | |||
| scaleLabel: { | |||
| display: false, | |||
| } | |||
| }], | |||
| yAxes: [{ | |||
| display: true, | |||
| gridLines: { | |||
| drawBorder: false, | |||
| color: window.chartColors.border, | |||
| }, | |||
| scaleLabel: { | |||
| display: false, | |||
| }, | |||
| ticks: { | |||
| beginAtZero: true, | |||
| userCallback: function(value, index, values) { | |||
| return value.toLocaleString() + '%'; | |||
| } | |||
| }, | |||
| }] | |||
| } | |||
| } | |||
| }; | |||
| //Bar Chart Demo | |||
| var barChartConfig = { | |||
| type: 'bar', | |||
| data: { | |||
| labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'], | |||
| datasets: [{ | |||
| label: 'Dataset 1', | |||
| backgroundColor: "rgba(117,193,129,0.8)", | |||
| hoverBackgroundColor: "rgba(117,193,129,1)", | |||
| data: [ | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint() | |||
| ] | |||
| }, | |||
| { | |||
| label: 'Dataset 2', | |||
| backgroundColor: "rgba(91,153,234,0.8)", | |||
| hoverBackgroundColor: "rgba(91,153,234,1)", | |||
| data: [ | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint() | |||
| ] | |||
| } | |||
| ] | |||
| }, | |||
| options: { | |||
| responsive: true, | |||
| legend: { | |||
| position: 'bottom', | |||
| align: 'end', | |||
| }, | |||
| tooltips: { | |||
| mode: 'index', | |||
| intersect: false, | |||
| titleMarginBottom: 10, | |||
| bodySpacing: 10, | |||
| xPadding: 16, | |||
| yPadding: 16, | |||
| borderColor: window.chartColors.border, | |||
| borderWidth: 1, | |||
| backgroundColor: '#fff', | |||
| bodyFontColor: window.chartColors.text, | |||
| titleFontColor: window.chartColors.text, | |||
| callbacks: { | |||
| label: function(tooltipItem, data) { | |||
| return tooltipItem.value + '%'; | |||
| } | |||
| }, | |||
| }, | |||
| scales: { | |||
| xAxes: [{ | |||
| display: true, | |||
| gridLines: { | |||
| drawBorder: false, | |||
| color: window.chartColors.border, | |||
| }, | |||
| }], | |||
| yAxes: [{ | |||
| display: true, | |||
| gridLines: { | |||
| drawBorder: false, | |||
| color: window.chartColors.borders, | |||
| }, | |||
| ticks: { | |||
| beginAtZero: true, | |||
| userCallback: function(value, index, values) { | |||
| return value + '%'; | |||
| } | |||
| }, | |||
| }] | |||
| } | |||
| } | |||
| } | |||
| // Pie Chart Demo | |||
| var pieChartConfig = { | |||
| type: 'pie', | |||
| data: { | |||
| datasets: [{ | |||
| data: [ | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| ], | |||
| backgroundColor: [ | |||
| window.chartColors.green, | |||
| window.chartColors.blue, | |||
| window.chartColors.gray, | |||
| ], | |||
| label: 'Dataset 1' | |||
| }], | |||
| labels: [ | |||
| 'Green', | |||
| 'Blue', | |||
| 'Gray', | |||
| ] | |||
| }, | |||
| options: { | |||
| responsive: true, | |||
| legend: { | |||
| display: true, | |||
| position: 'bottom', | |||
| align: 'center', | |||
| }, | |||
| tooltips: { | |||
| titleMarginBottom: 10, | |||
| bodySpacing: 10, | |||
| xPadding: 16, | |||
| yPadding: 16, | |||
| borderColor: window.chartColors.border, | |||
| borderWidth: 1, | |||
| backgroundColor: '#fff', | |||
| bodyFontColor: window.chartColors.text, | |||
| titleFontColor: window.chartColors.text, | |||
| /* Display % in tooltip - https://stackoverflow.com/questions/37257034/chart-js-2-0-doughnut-tooltip-percentages */ | |||
| callbacks: { | |||
| label: function(tooltipItem, data) { | |||
| //get the concerned dataset | |||
| var dataset = data.datasets[tooltipItem.datasetIndex]; | |||
| //calculate the total of this data set | |||
| var total = dataset.data.reduce(function(previousValue, currentValue, currentIndex, array) { | |||
| return previousValue + currentValue; | |||
| }); | |||
| //get the current items value | |||
| var currentValue = dataset.data[tooltipItem.index]; | |||
| //calculate the precentage based on the total and current item, also this does a rough rounding to give a whole number | |||
| var percentage = Math.floor(((currentValue/total) * 100)+0.5); | |||
| return percentage + "%"; | |||
| }, | |||
| }, | |||
| }, | |||
| } | |||
| }; | |||
| // Doughnut Chart Demo | |||
| var doughnutChartConfig = { | |||
| type: 'doughnut', | |||
| data: { | |||
| datasets: [{ | |||
| data: [ | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| ], | |||
| backgroundColor: [ | |||
| window.chartColors.green, | |||
| window.chartColors.blue, | |||
| window.chartColors.gray, | |||
| ], | |||
| label: 'Dataset 1' | |||
| }], | |||
| labels: [ | |||
| 'Green', | |||
| 'Blue', | |||
| 'Gray', | |||
| ] | |||
| }, | |||
| options: { | |||
| responsive: true, | |||
| legend: { | |||
| display: true, | |||
| position: 'bottom', | |||
| align: 'center', | |||
| }, | |||
| tooltips: { | |||
| titleMarginBottom: 10, | |||
| bodySpacing: 10, | |||
| xPadding: 16, | |||
| yPadding: 16, | |||
| borderColor: window.chartColors.border, | |||
| borderWidth: 1, | |||
| backgroundColor: '#fff', | |||
| bodyFontColor: window.chartColors.text, | |||
| titleFontColor: window.chartColors.text, | |||
| animation: { | |||
| animateScale: true, | |||
| animateRotate: true | |||
| }, | |||
| /* Display % in tooltip - https://stackoverflow.com/questions/37257034/chart-js-2-0-doughnut-tooltip-percentages */ | |||
| callbacks: { | |||
| label: function(tooltipItem, data) { | |||
| //get the concerned dataset | |||
| var dataset = data.datasets[tooltipItem.datasetIndex]; | |||
| //calculate the total of this data set | |||
| var total = dataset.data.reduce(function(previousValue, currentValue, currentIndex, array) { | |||
| return previousValue + currentValue; | |||
| }); | |||
| //get the current items value | |||
| var currentValue = dataset.data[tooltipItem.index]; | |||
| //calculate the precentage based on the total and current item, also this does a rough rounding to give a whole number | |||
| var percentage = Math.floor(((currentValue/total) * 100)+0.5); | |||
| return percentage + "%"; | |||
| }, | |||
| }, | |||
| }, | |||
| } | |||
| }; | |||
| // Generate charts on load | |||
| window.addEventListener('load', function(){ | |||
| var lineChart = document.getElementById('chart-line').getContext('2d'); | |||
| window.myLine = new Chart(lineChart, lineChartConfig); | |||
| var barChart = document.getElementById('chart-bar').getContext('2d'); | |||
| window.myBar = new Chart(barChart, barChartConfig); | |||
| var pieChart = document.getElementById('chart-pie').getContext('2d'); | |||
| window.myPie = new Chart(pieChart, pieChartConfig); | |||
| var doughnutChart = document.getElementById('chart-doughnut').getContext('2d'); | |||
| window.myDoughnut = new Chart(doughnutChart, doughnutChartConfig); | |||
| }); | |||
| @ -0,0 +1,224 @@ | |||
| 'use strict'; | |||
| /* Chart.js docs: https://www.chartjs.org/ */ | |||
| window.chartColors = { | |||
| green: '#75c181', | |||
| gray: '#a9b5c9', | |||
| text: '#252930', | |||
| border: '#e7e9ed' | |||
| }; | |||
| /* Random number generator for demo purpose */ | |||
| var randomDataPoint = function(){ return Math.round(Math.random()*10000)}; | |||
| //Chart.js Line Chart Example | |||
| var lineChartConfig = { | |||
| type: 'line', | |||
| data: { | |||
| labels: ['Day 1', 'Day 2', 'Day 3', 'Day 4', 'Day 5', 'Day 6', 'Day 7'], | |||
| datasets: [{ | |||
| label: 'Current week', | |||
| fill: false, | |||
| backgroundColor: window.chartColors.green, | |||
| borderColor: window.chartColors.green, | |||
| data: [ | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint() | |||
| ], | |||
| }, { | |||
| label: 'Previous week', | |||
| borderDash: [3, 5], | |||
| backgroundColor: window.chartColors.gray, | |||
| borderColor: window.chartColors.gray, | |||
| data: [ | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint(), | |||
| randomDataPoint() | |||
| ], | |||
| fill: false, | |||
| }] | |||
| }, | |||
| options: { | |||
| responsive: true, | |||
| aspectRatio: 1.5, | |||
| legend: { | |||
| display: true, | |||
| position: 'bottom', | |||
| align: 'end', | |||
| }, | |||
| title: { | |||
| display: true, | |||
| text: 'Chart.js Line Chart Example', | |||
| }, | |||
| tooltips: { | |||
| mode: 'index', | |||
| intersect: false, | |||
| titleMarginBottom: 10, | |||
| bodySpacing: 10, | |||
| xPadding: 16, | |||
| yPadding: 16, | |||
| borderColor: window.chartColors.border, | |||
| borderWidth: 1, | |||
| backgroundColor: '#fff', | |||
| bodyFontColor: window.chartColors.text, | |||
| titleFontColor: window.chartColors.text, | |||
| callbacks: { | |||
| //Ref: https://stackoverflow.com/questions/38800226/chart-js-add-commas-to-tooltip-and-y-axis | |||
| label: function(tooltipItem, data) { | |||
| if (parseInt(tooltipItem.value) >= 1000) { | |||
| return "$" + tooltipItem.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); | |||
| } else { | |||
| return '$' + tooltipItem.value; | |||
| } | |||
| } | |||
| }, | |||
| }, | |||
| hover: { | |||
| mode: 'nearest', | |||
| intersect: true | |||
| }, | |||
| scales: { | |||
| xAxes: [{ | |||
| display: true, | |||
| gridLines: { | |||
| drawBorder: false, | |||
| color: window.chartColors.border, | |||
| }, | |||
| scaleLabel: { | |||
| display: false, | |||
| } | |||
| }], | |||
| yAxes: [{ | |||
| display: true, | |||
| gridLines: { | |||
| drawBorder: false, | |||
| color: window.chartColors.border, | |||
| }, | |||
| scaleLabel: { | |||
| display: false, | |||
| }, | |||
| ticks: { | |||
| beginAtZero: true, | |||
| userCallback: function(value, index, values) { | |||
| return '$' + value.toLocaleString(); //Ref: https://stackoverflow.com/questions/38800226/chart-js-add-commas-to-tooltip-and-y-axis | |||
| } | |||
| }, | |||
| }] | |||
| } | |||
| } | |||
| }; | |||
| // Chart.js Bar Chart Example | |||
| var barChartConfig = { | |||
| type: 'bar', | |||
| data: { | |||
| labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], | |||
| datasets: [{ | |||
| label: 'Orders', | |||
| backgroundColor: window.chartColors.green, | |||
| borderColor: window.chartColors.green, | |||
| borderWidth: 1, | |||
| maxBarThickness: 16, | |||
| data: [ | |||
| 23, | |||
| 45, | |||
| 76, | |||
| 75, | |||
| 62, | |||
| 37, | |||
| 83 | |||
| ] | |||
| }] | |||
| }, | |||
| options: { | |||
| responsive: true, | |||
| aspectRatio: 1.5, | |||
| legend: { | |||
| position: 'bottom', | |||
| align: 'end', | |||
| }, | |||
| title: { | |||
| display: true, | |||
| text: 'Chart.js Bar Chart Example' | |||
| }, | |||
| tooltips: { | |||
| mode: 'index', | |||
| intersect: false, | |||
| titleMarginBottom: 10, | |||
| bodySpacing: 10, | |||
| xPadding: 16, | |||
| yPadding: 16, | |||
| borderColor: window.chartColors.border, | |||
| borderWidth: 1, | |||
| backgroundColor: '#fff', | |||
| bodyFontColor: window.chartColors.text, | |||
| titleFontColor: window.chartColors.text, | |||
| }, | |||
| scales: { | |||
| xAxes: [{ | |||
| display: true, | |||
| gridLines: { | |||
| drawBorder: false, | |||
| color: window.chartColors.border, | |||
| }, | |||
| }], | |||
| yAxes: [{ | |||
| display: true, | |||
| gridLines: { | |||
| drawBorder: false, | |||
| color: window.chartColors.borders, | |||
| }, | |||
| }] | |||
| } | |||
| } | |||
| } | |||
| // Generate charts on load | |||
| window.addEventListener('load', function(){ | |||
| var lineChart = document.getElementById('canvas-linechart').getContext('2d'); | |||
| window.myLine = new Chart(lineChart, lineChartConfig); | |||
| var barChart = document.getElementById('canvas-barchart').getContext('2d'); | |||
| window.myBar = new Chart(barChart, barChartConfig); | |||
| }); | |||