From 680aaf4924119d86af14c5eb55a499e24db2e287 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 21 Apr 2026 07:30:28 +0000 Subject: [PATCH] Initial commit: Cocina con Alma - Tandoor + public menu stack --- .env.example | 12 + .gitignore | 37 ++ docker-compose.yml | 50 +++ menu-publico/Dockerfile | 12 + menu-publico/app.py | 119 +++++ menu-publico/requirements.txt | 4 + menu-publico/templates/index.html | 163 +++++++ tandoor/scope_middleware.py | 108 +++++ tandoor/settings.py | 725 ++++++++++++++++++++++++++++++ 9 files changed, 1230 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 menu-publico/Dockerfile create mode 100644 menu-publico/app.py create mode 100644 menu-publico/requirements.txt create mode 100644 menu-publico/templates/index.html create mode 100644 tandoor/scope_middleware.py create mode 100644 tandoor/settings.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9bde3e2 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +DEBUG=0 +SQL_DEBUG=0 +ALLOWED_HOSTS=* +SECRET_KEY=cambia_esto_por_una_clave_secreta_larga_y_aleatoria +TZ=America/Argentina/Buenos_Aires +DB_ENGINE=django.db.backends.postgresql +POSTGRES_HOST=db_recipes +POSTGRES_PORT=5432 +POSTGRES_USER=djangouser +POSTGRES_PASSWORD=cambia_esta_contraseña +POSTGRES_DB=djangodb +TANDOOR_API_TOKEN=genera_este_token_desde_tandoor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b002683 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Datos sensibles +.env +*.key +*.pem + +# Base de datos local +tandoor/postgresql/ + +# Docker +.dockerignore + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Entorno +venv/ +env/ +ENV/ + +# Archivos temporales +*.log +*.tmp +get-docker.sh + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Sistema operativo +.DS_Store +Thumbs.db diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b014268 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + db_recipes: + restart: always + image: postgres:15-alpine + volumes: + - ./tandoor/postgresql:/var/lib/postgresql/data + env_file: + - ./.env + networks: + - cocina + + web_recipes: + restart: always + image: vabene1111/recipes:latest + env_file: + - ./.env + ports: + - "8080:80" + volumes: + - staticfiles:/opt/recipes/staticfiles + - mediafiles:/opt/recipes/mediafiles + - ./tandoor/settings.py:/opt/recipes/recipes/settings.py:ro + - ./tandoor/scope_middleware.py:/opt/recipes/cookbook/helper/scope_middleware.py:ro + depends_on: + - db_recipes + networks: + - cocina + + menu_publico: + build: ./menu-publico + restart: always + ports: + - "80:5000" + env_file: + - ./.env + environment: + - TANDOOR_URL=http://web_recipes:80 + - FLASK_ENV=production + depends_on: + - web_recipes + networks: + - cocina + +volumes: + staticfiles: + mediafiles: + +networks: + cocina: + driver: bridge diff --git a/menu-publico/Dockerfile b/menu-publico/Dockerfile new file mode 100644 index 0000000..31ccf49 --- /dev/null +++ b/menu-publico/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "app:app"] diff --git a/menu-publico/app.py b/menu-publico/app.py new file mode 100644 index 0000000..306a721 --- /dev/null +++ b/menu-publico/app.py @@ -0,0 +1,119 @@ +import os +import requests +from flask import Flask, render_template +from datetime import datetime, timedelta +from collections import defaultdict + +app = Flask(__name__) + +TANDOOR_URL = os.environ.get("TANDOOR_URL", "http://web_recipes:8080") +TANDOOR_API_TOKEN = os.environ.get("TANDOOR_API_TOKEN", "") + + +def get_meal_plan(): + if not TANDOOR_API_TOKEN: + return None, "Todavía no se configuró el token de API. Tu madre debe crearlo desde el panel de Tandoor." + + headers = { + "Authorization": f"Token {TANDOOR_API_TOKEN}", + "Content-Type": "application/json", + "Host": "tandoor.local", + } + + # Obtener meal plan de los próximos 7 días + today = datetime.now().date() + end = today + timedelta(days=6) + + params = { + "from_date": today.isoformat(), + "to_date": end.isoformat(), + } + + try: + resp = requests.get( + f"{TANDOOR_URL}/api/meal-plan/", + headers=headers, + params=params, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + # Tandoor devuelve paginación: { "count": ..., "results": [...] } + results = data.get("results", data) if isinstance(data, dict) else data + return results, None + except requests.exceptions.ConnectionError: + return None, "No se pudo conectar con Tandoor. ¿Está iniciado el servicio?" + except requests.exceptions.HTTPError as e: + if resp.status_code == 401: + return None, "Token de API inválido. Verificá la configuración." + return None, f"Error de API: {e}" + except Exception as e: + return None, f"Error inesperado: {e}" + + +def group_by_day(meal_plans): + days = defaultdict(list) + dias_semana = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"] + + for item in meal_plans: + date_str = item.get("from_date") or item.get("to_date") + if not date_str: + continue + try: + d = datetime.fromisoformat(date_str).date() + except Exception: + continue + + recipe = item.get("recipe") + title = None + image = None + if recipe: + title = recipe.get("name", "Sin nombre") + image = recipe.get("image") + else: + title = item.get("title", "Sin nombre") + + meal_type = item.get("meal_type", {}) + tipo = meal_type.get("name", "Menú") if meal_type else "Menú" + + days[d.isoformat()].append({ + "title": title, + "image": image, + "tipo": tipo, + "servings": item.get("servings", 1), + }) + + # Ordenar por fecha + sorted_days = [] + today = datetime.now().date() + for i in range(7): + d = today + timedelta(days=i) + key = d.isoformat() + sorted_days.append({ + "fecha": d, + "nombre_dia": dias_semana[d.weekday()], + "meals": days.get(key, []), + }) + + return sorted_days + + +@app.route("/") +def index(): + meal_plans, error = get_meal_plan() + if error: + return render_template("index.html", error=error, days=[]) + + if not meal_plans: + return render_template( + "index.html", + error="No hay platos planificados para esta semana. Tu madre puede cargarlos desde el panel de Tandoor.", + days=[], + ) + + days = group_by_day(meal_plans) + return render_template("index.html", error=None, days=days) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/menu-publico/requirements.txt b/menu-publico/requirements.txt new file mode 100644 index 0000000..61ea808 --- /dev/null +++ b/menu-publico/requirements.txt @@ -0,0 +1,4 @@ +flask==3.0.3 +requests==2.31.0 +gunicorn==23.0.0 +python-dateutil==2.9.0 diff --git a/menu-publico/templates/index.html b/menu-publico/templates/index.html new file mode 100644 index 0000000..bde23ab --- /dev/null +++ b/menu-publico/templates/index.html @@ -0,0 +1,163 @@ + + + + + + Menú Semanal - Cocina con Alma + + + +
+

🍽️ Menú Semanal

+

Consultá lo que preparamos esta semana

+
+ +
+ {% if error %} +
+ ⚠️ {{ error }} +
+ {% endif %} + + {% for day in days %} +
+
+ {{ day.nombre_dia }} + {{ day.fecha.strftime('%d/%m') }} +
+ {% if day.meals %} + {% for item in day.meals %} +
+ {% if item.image %} + {{ item.title }} + {% else %} +
🍲
+ {% endif %} +
+

{{ item.title }}

+ {{ item.tipo }} +
+
+ {% endfor %} + {% else %} +
Sin platos planificados para este día.
+ {% endif %} +
+ {% endfor %} +
+ + + + diff --git a/tandoor/scope_middleware.py b/tandoor/scope_middleware.py new file mode 100644 index 0000000..79fc339 --- /dev/null +++ b/tandoor/scope_middleware.py @@ -0,0 +1,108 @@ +import re + +from django.http import HttpResponseRedirect +from django.urls import reverse +from django_scopes import scope, scopes_disabled +from oauth2_provider.contrib.rest_framework import OAuth2Authentication +from rest_framework.authentication import TokenAuthentication +from rest_framework.exceptions import AuthenticationFailed + + +from cookbook.helper.permission_helper import create_space_for_user +from cookbook.views import views +from recipes import settings + + +class ScopeMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + prefix = settings.SCRIPT_NAME or '' + + # need to disable scopes for writing requests into userpref and enable for loading ? + if request.path.startswith(prefix + '/api/user-preference/'): + with scopes_disabled(): + return self.get_response(request) + + # Disable scopes for recipe detail requests with share link + # This allows users from different spaces to access shared recipes + # Security is maintained by CustomRecipePermission which validates the share link + if (request.GET.get('share') + and re.match(rf'^{re.escape(prefix)}/api/recipe/\d+/?$', request.path) + and request.method in ('GET', 'HEAD', 'OPTIONS')): + with scopes_disabled(): + request.space = None + return self.get_response(request) + + if request.user.is_authenticated: + + if request.path.startswith(prefix + '/admin/'): + with scopes_disabled(): + return self.get_response(request) + + if request.path.startswith(prefix + '/signup/') or request.path.startswith(prefix + '/invite/'): + return self.get_response(request) + + if request.path.startswith(prefix + '/accounts/'): + return self.get_response(request) + + if request.path.startswith(prefix + '/switch-space/'): + return self.get_response(request) + + if request.path.startswith(prefix + '/invite/'): + return self.get_response(request) + + # get active user space, if for some reason more than one space is active select first (group permission checks will fail, this is not intended at this point) + user_space = request.user.userspace_set.filter(active=True).first() + + if not user_space and request.user.userspace_set.count() > 0: + # if the users has a userspace but nothing is active, activate the first one + user_space = request.user.userspace_set.first() + if user_space: + user_space.active = True + user_space.save() + + if not user_space: + if 'signup_token' in request.session: + # if user is authenticated, has no space but a signup token (InviteLink) is present, redirect to invite link logic + return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')])) + else: + # if user does not yet have a space create one for him + user_space = create_space_for_user(request.user) + + # TODO remove the need for this view + if user_space.groups.count() == 0 and not reverse('account_logout') in request.path: + return views.no_groups(request) + + request.space = user_space.space + request.user_space = user_space + with scope(space=request.space): + return self.get_response(request) + else: + if request.path.startswith(prefix + '/api/'): + try: + if auth := OAuth2Authentication().authenticate(request): + user_space = auth[0].userspace_set.filter(active=True).first() + if user_space: + request.space = user_space.space + request.user_space = user_space + with scope(space=request.space): + return self.get_response(request) + except AuthenticationFailed: + pass + + try: + if auth := TokenAuthentication().authenticate(request): + user_space = auth[0].userspace_set.filter(active=True).first() + if user_space: + request.space = user_space.space + request.user_space = user_space + with scope(space=request.space): + return self.get_response(request) + except AuthenticationFailed: + pass + + with scopes_disabled(): + request.space = None + return self.get_response(request) diff --git a/tandoor/settings.py b/tandoor/settings.py new file mode 100644 index 0000000..af76da5 --- /dev/null +++ b/tandoor/settings.py @@ -0,0 +1,725 @@ +""" +Django settings for recipes project. + +Generated by 'django-admin startproject' using Django 2.0.1. + +For more information on this file, see +https://docs.djangoproject.com/en/2.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.0/ref/settings/ +""" +import ast +import json +import mimetypes +import os +import re +import socket +import sys +import traceback + +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ +from dotenv import load_dotenv + + +def extract_bool(env_key, default): + return bool(int(os.getenv(env_key, default))) + + +def extract_comma_list(env_key, default=None): + if os.getenv(env_key): + return [item.strip() for item in os.getenv(env_key).split(',')] + else: + if default: + return [default] + else: + return [] + + +load_dotenv() +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPT_NAME = os.getenv('SCRIPT_NAME', '') +FORCE_SCRIPT_NAME = SCRIPT_NAME or None + +STATIC_URL = os.getenv('STATIC_URL', f'{SCRIPT_NAME}/static/') +STATIC_ROOT = os.getenv('STATIC_ROOT', os.path.join(BASE_DIR, "staticfiles")) + +# Get vars from .env files +SECRET_KEY = os.getenv('SECRET_KEY', 'INSECURE_STANDARD_KEY_SET_IN_ENV') + +DEBUG = bool(int(os.getenv('DEBUG', '0'))) +DEBUG_TOOLBAR = bool(int(os.getenv('DEBUG_TOOLBAR', '0'))) + +LOG_LEVEL = os.getenv("LOG_LEVEL", "WARNING") + +SOCIAL_DEFAULT_ACCESS = bool(int(os.getenv('SOCIAL_DEFAULT_ACCESS', False))) +SOCIAL_DEFAULT_GROUP = os.getenv('SOCIAL_DEFAULT_GROUP', 'guest') + +HIDE_LOGIN_FORM = bool(int(os.getenv('HIDE_LOGIN_FORM', False))) + +SPACE_DEFAULT_MAX_RECIPES = int(os.getenv('SPACE_DEFAULT_MAX_RECIPES', 0)) +SPACE_DEFAULT_MAX_USERS = int(os.getenv('SPACE_DEFAULT_MAX_USERS', 0)) +SPACE_DEFAULT_MAX_FILES = int(os.getenv('SPACE_DEFAULT_MAX_FILES', 0)) +SPACE_DEFAULT_ALLOW_SHARING = extract_bool('SPACE_DEFAULT_ALLOW_SHARING', True) +SPACE_AI_ENABLED = extract_bool('SPACE_AI_ENABLED', True) +SPACE_AI_CREDITS_MONTHLY = int(os.getenv('SPACE_AI_CREDITS_MONTHLY', 10000)) + +INTERNAL_IPS = extract_comma_list('INTERNAL_IPS', '127.0.0.1') + +# Django Logging +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + 'formatters': { + 'verbose': { + "format": "{threadName} {levelname} {asctime} {name} {message}", + 'style': '{', + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + 'formatter': 'verbose', + }, + }, + "loggers": { + 'recipes': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + }, + 'django': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + }, + }, +} + +# zip import limits (in MB) +MAX_ZIP_FILE_SIZE = int(os.getenv('MAX_ZIP_FILE_SIZE', 10)) * 1024 * 1024 # default 10MB +MAX_ZIP_TOTAL_SIZE = int(os.getenv('MAX_ZIP_TOTAL_SIZE', 500)) * 1024 * 1024 # default 500MB +MAX_ZIP_FILE_COUNT = int(os.getenv('MAX_ZIP_FILE_COUNT', 2000)) +MAX_ZIP_NESTING_DEPTH = int(os.getenv('MAX_ZIP_NESTING_DEPTH', 2)) + +# allow djangos wsgi server to server mediafiles +GUNICORN_MEDIA = extract_bool('GUNICORN_MEDIA', False) + +if os.getenv('REVERSE_PROXY_AUTH') is not None: + print('DEPRECATION WARNING: Environment var "REVERSE_PROXY_AUTH" is deprecated. Please use "REMOTE_USER_AUTH".') + REMOTE_USER_AUTH = extract_bool('REVERSE_PROXY_AUTH', False) +else: + REMOTE_USER_AUTH = extract_bool('REMOTE_USER_AUTH', False) + +# default value for user preference 'comment' +COMMENT_PREF_DEFAULT = extract_bool('COMMENT_PREF_DEFAULT', True) +FRACTION_PREF_DEFAULT = extract_bool('FRACTION_PREF_DEFAULT', False) +KJ_PREF_DEFAULT = extract_bool('KJ_PREF_DEFAULT', False) +STICKY_NAV_PREF_DEFAULT = extract_bool('STICKY_NAV_PREF_DEFAULT', True) +MAX_OWNED_SPACES_PREF_DEFAULT = int(os.getenv('MAX_OWNED_SPACES_PREF_DEFAULT', 100)) +UNAUTHENTICATED_THEME_FROM_SPACE = int(os.getenv('UNAUTHENTICATED_THEME_FROM_SPACE', 0)) +FORCE_THEME_FROM_SPACE = int(os.getenv('FORCE_THEME_FROM_SPACE', 0)) + +# minimum interval that users can set for automatic sync of shopping lists +SHOPPING_MIN_AUTOSYNC_INTERVAL = int(os.getenv('SHOPPING_MIN_AUTOSYNC_INTERVAL', 5)) + +ALLOWED_HOSTS = extract_comma_list('ALLOWED_HOSTS', '') +CSRF_TRUSTED_ORIGINS = extract_comma_list('CSRF_TRUSTED_ORIGINS') + +if CORS_ORIGIN_ALLOW_ALL := os.getenv('CORS_ORIGIN_ALLOW_ALL') is not None: + print('DEPRECATION WARNING: Environment var "CORS_ORIGIN_ALLOW_ALL" is deprecated. Please use "CORS_ALLOW_ALL_ORIGINS."') + CORS_ALLOW_ALL_ORIGINS = CORS_ORIGIN_ALLOW_ALL +else: + CORS_ALLOW_ALL_ORIGINS = extract_bool("CORS_ALLOW_ALL_ORIGINS", True) + +LOGIN_REDIRECT_URL = "index" +LOGOUT_REDIRECT_URL = "index" +ACCOUNT_LOGOUT_REDIRECT_URL = "index" +ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = "index" + +SESSION_EXPIRE_AT_BROWSER_CLOSE = False +SESSION_COOKIE_AGE = 365 * 60 * 24 * 60 + +CRISPY_TEMPLATE_PACK = 'bootstrap4' +DJANGO_TABLES2_TEMPLATE = 'cookbook/templates/generic/table_template.html' +DJANGO_TABLES2_PAGE_RANGE = 8 + +HCAPTCHA_SITEKEY = os.getenv('HCAPTCHA_SITEKEY', '') +HCAPTCHA_SECRET = os.getenv('HCAPTCHA_SECRET', '') + +FDC_API_KEY = os.getenv('FDC_API_KEY', 'DEMO_KEY') + +AI_RATELIMIT = os.getenv('AI_RATELIMIT', '60/hour') + +SHARING_ABUSE = extract_bool('SHARING_ABUSE', False) +SHARING_LIMIT = int(os.getenv('SHARING_LIMIT', 0)) + +DRF_THROTTLE_RECIPE_URL_IMPORT = os.getenv('DRF_THROTTLE_RECIPE_URL_IMPORT', os.getenv('RATELIMIT_URL_IMPORT_REQUESTS', '60/hour')) + +TERMS_URL = os.getenv('TERMS_URL', '') +PRIVACY_URL = os.getenv('PRIVACY_URL', '') +IMPRINT_URL = os.getenv('IMPRINT_URL', '') +HOSTED = extract_bool('HOSTED', False) + +REDIS_HOST = os.getenv('REDIS_HOST', None) +REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) +REDIS_USERNAME = os.getenv('REDIS_USERNAME', None) +REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', None) +REDIS_DATABASES = { + 'STATS': 0 +} + +MESSAGE_TAGS = {messages.ERROR: 'danger'} + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.sites', + 'django.contrib.staticfiles', + 'django.contrib.humanize', + 'django.contrib.postgres', + 'oauth2_provider', + 'corsheaders', + 'crispy_forms', + 'crispy_bootstrap4', + 'rest_framework', + 'rest_framework.authtoken', + 'drf_spectacular', + 'drf_spectacular_sidecar', + 'django_cleanup.apps.CleanupConfig', + 'django_vite', + 'hcaptcha', + 'django.db.migrations', + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.headless', + 'allauth.mfa', + 'allauth.usersessions', + + 'cookbook.apps.CookbookConfig', + 'treebeard', +] + +PLUGINS_DIRECTORY = os.path.join(BASE_DIR, 'recipes', 'plugins') +PLUGINS = [] +try: + if os.path.isdir(PLUGINS_DIRECTORY): + for d in os.listdir(PLUGINS_DIRECTORY): + if d != '__pycache__': + try: + apps_path = f'recipes.plugins.{d}.apps' + __import__(apps_path) + app_config_classname = dir(sys.modules[apps_path])[1] + plugin_module = f'recipes.plugins.{d}.apps.{app_config_classname}' + plugin_class = getattr(sys.modules[apps_path], app_config_classname) + plugin_disabled = False + if hasattr(plugin_class, 'disabled'): + plugin_disabled = plugin_class.disabled + if plugin_module not in INSTALLED_APPS and not plugin_disabled: + INSTALLED_APPS.append(plugin_module) + + plugin_config = { + 'name': plugin_class.verbose_name if hasattr(plugin_class, 'verbose_name') else plugin_class.name, + 'version': plugin_class.VERSION if hasattr(plugin_class, 'VERSION') else 'unknown', + 'website': plugin_class.website if hasattr(plugin_class, 'website') else '', + 'github': plugin_class.github if hasattr(plugin_class, 'github') else '', + 'module': f'recipes.plugins.{d}', + 'base_path': os.path.join(BASE_DIR, 'recipes', 'plugins', d), + 'base_url': plugin_class.base_url, + 'api_router_name': plugin_class.api_router_name if hasattr(plugin_class, 'api_router_name') else '', + } + PLUGINS.append(plugin_config) + print(f'PLUGIN {d} loaded') + except Exception: + if DEBUG: + traceback.print_exc() + print(f'ERROR failed to initialize plugin {d}') +except Exception: + if DEBUG: + print('ERROR failed to initialize plugins') + +SOCIAL_PROVIDERS = extract_comma_list('SOCIAL_PROVIDERS') +SOCIALACCOUNT_EMAIL_VERIFICATION = 'none' +SOCIALACCOUNT_EMAIL_AUTHENTICATION = extract_bool('SOCIALACCOUNT_EMAIL_AUTHENTICATION', False) +SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = extract_bool('SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT', False) +SOCIALACCOUNT_LOGIN_ON_GET = extract_bool('SOCIALACCOUNT_LOGIN_ON_GET', False) +if os.getenv('SOCIALACCOUNT_AUTO_SIGNUP') is not None: + SOCIALACCOUNT_AUTO_SIGNUP = extract_bool('SOCIALACCOUNT_AUTO_SIGNUP', True) +SOCIALACCOUNT_ONLY = extract_bool('SOCIALACCOUNT_ONLY', False) +if SOCIALACCOUNT_ONLY and not SOCIAL_PROVIDERS: + print('WARNING: SOCIALACCOUNT_ONLY is enabled but no SOCIAL_PROVIDERS are configured. Users will be unable to log in!') +if HIDE_LOGIN_FORM and not SOCIAL_PROVIDERS and not REMOTE_USER_AUTH: + print('WARNING: HIDE_LOGIN_FORM is enabled but no SOCIAL_PROVIDERS or REMOTE_USER_AUTH are configured. Users will be unable to log in!') +if SOCIALACCOUNT_EMAIL_AUTHENTICATION and not SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT and os.getenv('EMAIL_HOST', '') == '': + print('WARNING: SOCIALACCOUNT_EMAIL_AUTHENTICATION requires a working email configuration (EMAIL_HOST) when SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT is not enabled.') +INSTALLED_APPS = INSTALLED_APPS + SOCIAL_PROVIDERS + +ACCOUNT_MAX_EMAIL_ADDRESSES = 3 +ACCOUNT_SIGNUP_FIELDS = ['email*', 'email2*', 'username*', 'password1*', 'password2*'] +ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 90 +ACCOUNT_LOGOUT_ON_GET = True + +USERSESSIONS_TRACK_ACTIVITY = True +HEADLESS_SERVE_SPECIFICATION = True + +SOCIALACCOUNT_ADAPTER = 'cookbook.helper.social_adapter.TandoorSocialAccountAdapter' + +try: + SOCIALACCOUNT_PROVIDERS = ast.literal_eval(os.getenv('SOCIALACCOUNT_PROVIDERS') if os.getenv('SOCIALACCOUNT_PROVIDERS') else '{}') +except ValueError: + SOCIALACCOUNT_PROVIDERS = json.loads(os.getenv('SOCIALACCOUNT_PROVIDERS').replace("'", '"') if os.getenv('SOCIALACCOUNT_PROVIDERS') else '{}') + +SESSION_COOKIE_DOMAIN = os.getenv('SESSION_COOKIE_DOMAIN', None) +SESSION_COOKIE_NAME = os.getenv('SESSION_COOKIE_NAME', 'sessionid') + +ENABLE_SIGNUP = extract_bool('ENABLE_SIGNUP', False) + +ENABLE_METRICS = extract_bool('ENABLE_METRICS', False) + +# ENABLE_PDF_EXPORT = extract_bool('ENABLE_PDF_EXPORT', False) # Removed: pyppeteer dependency removed +EXPORT_FILE_CACHE_DURATION = int(os.getenv('EXPORT_FILE_CACHE_DURATION', 600)) + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'allauth.usersessions.middleware.UserSessionsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'cookbook.helper.scope_middleware.ScopeMiddleware', + 'allauth.account.middleware.AccountMiddleware', +] + +if DEBUG_TOOLBAR: + MIDDLEWARE += ('debug_toolbar.middleware.DebugToolbarMiddleware',) + INSTALLED_APPS += ('debug_toolbar',) + +SORT_TREE_BY_NAME = extract_bool('SORT_TREE_BY_NAME', False) +DISABLE_TREE_FIX_STARTUP = extract_bool('DISABLE_TREE_FIX_STARTUP', False) + +if bool(int(os.getenv('SQL_DEBUG', False))): + MIDDLEWARE += ('recipes.middleware.SqlPrintingMiddleware',) + +if ENABLE_METRICS: + MIDDLEWARE += 'django_prometheus.middleware.PrometheusAfterMiddleware', + INSTALLED_APPS += 'django_prometheus', + +# Auth related settings +AUTHENTICATION_BACKENDS = [] + +# LDAP +LDAP_AUTH = bool(os.getenv('LDAP_AUTH', False)) +if LDAP_AUTH: + import ldap + from django_auth_ldap.config import LDAPSearch + + AUTHENTICATION_BACKENDS.append('django_auth_ldap.backend.LDAPBackend') + AUTH_LDAP_SERVER_URI = os.getenv('AUTH_LDAP_SERVER_URI') + AUTH_LDAP_START_TLS = extract_bool('AUTH_LDAP_START_TLS', False) + AUTH_LDAP_BIND_DN = os.getenv('AUTH_LDAP_BIND_DN') + AUTH_LDAP_BIND_PASSWORD = os.getenv('AUTH_LDAP_BIND_PASSWORD') + AUTH_LDAP_USER_SEARCH = LDAPSearch( + os.getenv('AUTH_LDAP_USER_SEARCH_BASE_DN'), + ldap.SCOPE_SUBTREE, + os.getenv('AUTH_LDAP_USER_SEARCH_FILTER_STR', '(uid=%(user)s)'), + ) + AUTH_LDAP_USER_ATTR_MAP = ast.literal_eval(os.getenv('AUTH_LDAP_USER_ATTR_MAP')) if os.getenv('AUTH_LDAP_USER_ATTR_MAP') else { + 'first_name': 'givenName', + 'last_name': 'sn', + 'email': 'mail', + } + AUTH_LDAP_ALWAYS_UPDATE_USER = extract_bool('AUTH_LDAP_ALWAYS_UPDATE_USER', True) + AUTH_LDAP_CACHE_TIMEOUT = int(os.getenv('AUTH_LDAP_CACHE_TIMEOUT', 3600)) + if 'AUTH_LDAP_TLS_CACERTFILE' in os.environ: + AUTH_LDAP_GLOBAL_OPTIONS = {ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE')} + if DEBUG: + LOGGING["loggers"]["django_auth_ldap"] = { + "level": "DEBUG", + "handlers": ["console"] + } + +AUTHENTICATION_BACKENDS += [ + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +] + +# django allauth site id +SITE_ID = int(os.getenv('ALLAUTH_SITE_ID', 1)) + +ACCOUNT_ADAPTER = 'cookbook.helper.AllAuthCustomAdapter' + +if REMOTE_USER_AUTH: + MIDDLEWARE.insert(8, 'recipes.middleware.CustomRemoteUser') + AUTHENTICATION_BACKENDS.append('django.contrib.auth.backends.RemoteUserBackend') + +# Password validation +# https://docs.djangoproject.com/en/2.0/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' + }, +] + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +X_FRAME_OPTIONS = "SAMEORIGIN" + +OAUTH2_PROVIDER = {'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'bookmarklet': 'only access to bookmarklet', 'mealplan': 'only access to mealplan'}} +READ_SCOPE = 'read' +WRITE_SCOPE = 'write' + +################################################################## +####### change DEFAULT_SCHEMA_CLASS below to regenerate legacy API +################################################################## + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.IsAuthenticated'], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + # 'DEFAULT_SCHEMA_CLASS': 'cookbook.helper.drf_spectacular_hooks.LegacySchema', + 'COERCE_DECIMAL_TO_STRING': False, +} + +################################################################## +####### change DEFAULT_SCHEMA_CLASS above to regenerate legacy API +################################################################## + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Tandoor', + 'DESCRIPTION': 'Tandoor API Docs', + 'SERVE_INCLUDE_SCHEMA': False, + 'COMPONENT_SPLIT_REQUEST': False, + 'ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE': False, + "AUTHENTICATION_WHITELIST": [], + "APPEND_COMPONENTS": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + } + } + }, + "SECURITY": [{ + "ApiKeyAuth": [] + }], + 'SWAGGER_UI_DIST': 'SIDECAR', + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', + 'EXTENSIONS_INFO': { + "x-logo": { + "url": f"{STATIC_URL}assets/brand_logo.svg", + "backgroundColor": "#FFFFFF", + "altText": "Tandoor logo", + 'href': '/' + } + }, + 'CAMELIZE_NAMES': True, + "SWAGGER_UI_SETTINGS": { + "deepLinking": True, + "persistAuthorization": True, + "hideDownloadButton": False, + 'schemaExpansionLevel': 'all', + 'showExtensions': True + }, + 'POSTPROCESSING_HOOKS': ['drf_spectacular.hooks.postprocess_schema_enums', 'cookbook.helper.drf_spectacular_hooks.custom_postprocessing_hook'] +} + +ROOT_URLCONF = 'recipes.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates'), os.path.join(BASE_DIR, 'cookbook', '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', + 'django.template.context_processors.media', + 'cookbook.helper.context_processors.context_settings', + ], + }, + }, +] + +WSGI_APPLICATION = 'recipes.wsgi.application' + +# Database +# Load settings from env files +DATABASE_URL = os.getenv('DATABASE_URL', None) +DB_OPTIONS = os.getenv('DB_OPTIONS', None) +DB_ENGINE = os.getenv('DB_ENGINE', None) +POSTGRES_HOST = os.getenv('POSTGRES_HOST', None) +POSTGRES_PORT = os.getenv('POSTGRES_PORT', None) +POSTGRES_USER = os.getenv('POSTGRES_USER', None) +POSTGRES_PASSWORD = os.getenv('POSTGRES_PASSWORD', None) +POSTGRES_DB = os.getenv('POSTGRES_DB', None) + + +def setup_database(db_url=None, db_options=None, db_engine=None, pg_host=None, pg_port=None, pg_user=None, pg_password=None, pg_db=None): + global DATABASE_URL, DB_ENGINE, DB_OPTIONS, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, DATABASES + + has_individual_overrides = any(v is not None for v in [pg_host, pg_port, pg_user, pg_password, pg_db, db_engine]) + + DATABASE_URL = db_url or DATABASE_URL + DB_OPTIONS = db_options or DB_OPTIONS + DB_ENGINE = db_engine or DB_ENGINE + POSTGRES_HOST = pg_host or POSTGRES_HOST + POSTGRES_PORT = pg_port or POSTGRES_PORT + POSTGRES_USER = pg_user or POSTGRES_USER + POSTGRES_PASSWORD = pg_password or POSTGRES_PASSWORD + POSTGRES_DB = pg_db or POSTGRES_DB + + if has_individual_overrides and not db_url: + DATABASE_URL = None + + if DATABASE_URL: + match = re.match(r'(?P\w+):\/\/(?:(?P[\w\d_-]+)(?::(?P[^@]+))?@)?(?P[^:/]+)(?::(?P\d+))?(?:/(?P[\w\d/._-]+))?', DATABASE_URL) + settings = match.groupdict() + schema = settings['schema'] + if schema.startswith('postgres'): + engine = 'django.db.backends.postgresql' + elif schema == 'sqlite': + if (db_path := os.path.dirname(settings['database'])) and not os.path.exists(db_path): + os.makedirs(db_path) + engine = 'django.db.backends.sqlite3' + else: + raise Exception("Unsupported database schema: '%s'" % schema) + + DATABASES = { + 'default': { + 'ENGINE': engine, + 'OPTIONS': ast.literal_eval(DB_OPTIONS) if DB_OPTIONS else {}, + 'HOST': settings['host'], + 'PORT': settings['port'], + 'USER': settings['user'], + 'PASSWORD': settings['password'], + 'NAME': settings['database'], + 'CONN_MAX_AGE': 600, + } + } + else: + DATABASES = { + 'default': { + 'ENGINE': DB_ENGINE if DB_ENGINE else 'django.db.backends.sqlite3', + 'OPTIONS': ast.literal_eval(DB_OPTIONS) if DB_OPTIONS else {}, + 'HOST': POSTGRES_HOST, + 'PORT': POSTGRES_PORT, + 'USER': POSTGRES_USER, + 'PASSWORD': POSTGRES_PASSWORD, + 'NAME': POSTGRES_DB if POSTGRES_DB else 'db.sqlite3', + 'CONN_MAX_AGE': 60, + } + } + return DATABASES + + +DATABASES = setup_database() + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'default', + } +} + +if REDIS_HOST: + CACHES['default'] = { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': f'redis://{REDIS_HOST}:{REDIS_PORT}', + } + if REDIS_USERNAME and not REDIS_PASSWORD: + CACHES['default']['LOCATION'] = f'redis://{REDIS_USERNAME}@{REDIS_HOST}:{REDIS_PORT}' + if REDIS_USERNAME and REDIS_PASSWORD: + CACHES['default']['LOCATION'] = f'redis://{REDIS_USERNAME}:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}' + +# Vue webpack settings +VUE_DIR = os.path.join(BASE_DIR, 'vue') + +DJANGO_VITE = { + "default": { + "dev_mode": False, + "static_url_prefix": 'vue3', + 'manifest_path': os.path.join(BASE_DIR, 'cookbook/static/vue3/manifest.json'), + "dev_server_port": 5173, + "dev_server_host": os.getenv('DJANGO_VITE_DEV_SERVER_HOST', 'localhost'), + }, +} + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.001) + try: + s.connect((DJANGO_VITE['default']['dev_server_host'], DJANGO_VITE['default']['dev_server_port'])) + if DEBUG: + print("Vite Dev Server is running") + DJANGO_VITE['default']['dev_mode'] = True + else: + raise Exception('Debug not True, running in production mode') + except Exception: + print("Running django-vite in production mode (no HMR)") + +# Internationalization +# https://docs.djangoproject.com/en/2.0/topics/i18n/ + +LANGUAGE_CODE = 'en' + +if os.getenv('TIMEZONE') is not None: + print('DEPRECATION WARNING: Environment var "TIMEZONE" is deprecated. Please use "TZ" instead.') + TIME_ZONE = os.getenv('TIMEZONE') if os.getenv('TIMEZONE') else 'Europe/Berlin' +else: + TIME_ZONE = os.getenv('TZ') if os.getenv('TZ') else 'Europe/Berlin' + +USE_I18N = True + +USE_TZ = True + + +def _discover_languages(): + """Auto-discover languages from Weblate-created locale directories.""" + from django.conf.locale import LANG_INFO + + # Weblate directory names that map to different Django LANG_INFO keys. + # The value becomes both the LANG_INFO lookup key AND the language code. + DIR_CODE_MAP = { + 'hu-hu': 'hu', # Weblate uses hu_HU, Django uses hu + 'zh-cn': 'zh-hans', # Weblate uses zh_CN, Django uses zh-hans + } + + locale_dir = os.path.join(BASE_DIR, 'cookbook', 'locale') + languages = [] + + if not os.path.isdir(locale_dir): + return [('en', _('English'))] + + for entry in sorted(os.listdir(locale_dir)): + po_path = os.path.join(locale_dir, entry, 'LC_MESSAGES', 'django.po') + if not os.path.isfile(po_path): + continue + + dir_code = entry.replace('_', '-').lower() # nb_NO → nb-no + + # Remap known mismatches, otherwise use directory-derived code + lang_code = DIR_CODE_MAP.get(dir_code, dir_code) + + # Get English name from LANG_INFO + name = None + for candidate in [lang_code, lang_code.split('-')[0]]: + info = LANG_INFO.get(candidate, {}) + name = info.get('name') + if name: + break + + languages.append((lang_code, _(name) if name else entry)) + + if not any(code == 'en' for code, _name in languages): + languages.append(('en', _('English'))) + + return sorted(languages, key=lambda x: x[0]) + + +LANGUAGES = _discover_languages() + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.0/howto/static-files/ + +AWS_ENABLED = True if os.getenv('S3_ACCESS_KEY', False) else False + +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + # Serve static files with gzip + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +if os.getenv('S3_ACCESS_KEY', ''): + STORAGES['default']['BACKEND'] = 'cookbook.helper.CustomStorageClass.CachedS3Boto3Storage' + + AWS_ACCESS_KEY_ID = os.getenv('S3_ACCESS_KEY', '') + AWS_SECRET_ACCESS_KEY = os.getenv('S3_SECRET_ACCESS_KEY', '') + AWS_STORAGE_BUCKET_NAME = os.getenv('S3_BUCKET_NAME', '') + AWS_QUERYSTRING_AUTH = extract_bool('S3_QUERYSTRING_AUTH', True) + AWS_QUERYSTRING_EXPIRE = int(os.getenv('S3_QUERYSTRING_EXPIRE', 3600)) + AWS_S3_SIGNATURE_VERSION = os.getenv('S3_SIGNATURE_VERSION', 's3v4') + AWS_S3_REGION_NAME = os.getenv('S3_REGION_NAME', None) + + if os.getenv('S3_ENDPOINT_URL', ''): + AWS_S3_ENDPOINT_URL = os.getenv('S3_ENDPOINT_URL', '') + if os.getenv('S3_CUSTOM_DOMAIN', ''): + AWS_S3_CUSTOM_DOMAIN = os.getenv('S3_CUSTOM_DOMAIN', '') + +MEDIA_URL = os.getenv('MEDIA_URL', '/media/') +MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, "mediafiles")) +LOCAL_STORAGE_PATHS = extract_comma_list('LOCAL_STORAGE_PATHS', os.path.join(MEDIA_ROOT, 'local_provider')) + +# settings for cross site origin (CORS) +# all origins allowed to support bookmarklet +# all of this may or may not work with nginx or other web servers +# TODO make this user configurable - enable or disable bookmarklets +# TODO since token auth is enabled - this all should be https by default +CORS_ORIGIN_ALLOW_ALL = True + +# enable CORS only for bookmarklet api and only for posts, get and options +CORS_URLS_REGEX = r'^/api/bookmarklet-import.*$' +CORS_ALLOW_METHODS = ['GET', 'OPTIONS', 'POST'] +# future versions of django will make undeclared default django.db.models.BigAutoField which will force migrations on all models +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + +EMAIL_HOST = os.getenv('EMAIL_HOST', '') +EMAIL_PORT = int(os.getenv('EMAIL_PORT', 25)) +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') +EMAIL_USE_TLS = extract_bool('EMAIL_USE_TLS', False) +EMAIL_USE_SSL = extract_bool('EMAIL_USE_SSL', False) +DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost') +ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix + +# ACCOUNT_SIGNUP_FORM_CLASS = 'cookbook.forms.AllAuthSignupForm' +ACCOUNT_FORMS = {'signup': 'cookbook.forms.AllAuthSignupForm', 'reset_password': 'cookbook.forms.CustomPasswordResetForm'} +SOCIALACCOUNT_FORMS = { + 'signup': 'cookbook.forms.AllAuthSocialSignupForm', +} + +ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False +ALLAUTH_TRUSTED_PROXY_COUNT = int(os.getenv('ALLAUTH_TRUSTED_PROXY_COUNT', 1)) +ACCOUNT_RATE_LIMITS = { + "change_password": "1/m/user", + "reset_password": "1/m/ip,1/m/key", + "reset_password_from_key": "1/m/ip", + "signup": "5/m/ip", + "login": "5/m/ip", +} + +DISABLE_EXTERNAL_CONNECTORS = extract_bool('DISABLE_EXTERNAL_CONNECTORS', False) +EXTERNAL_CONNECTORS_QUEUE_SIZE = int(os.getenv('EXTERNAL_CONNECTORS_QUEUE_SIZE', 100)) + +mimetypes.add_type("text/javascript", ".js", True) +mimetypes.add_type("text/javascript", ".mjs", True)