diff --git a/.gitignore b/.gitignore index 004b3177..9ff5bac5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ judging/ plan.md +# Local runtime logs +logs/ +!logs/.gitkeep +!logs/**/.gitkeep + diff --git a/app/__init__.py b/app/__init__.py index b8a616af..62761323 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,13 @@ import os +import json +import uuid +import logging +import time +from logging.handlers import RotatingFileHandler +from datetime import datetime, timezone from dotenv import load_dotenv -from flask import Flask, jsonify +from flask import Flask, current_app, jsonify, g, request from flask_cors import CORS from app.database import init_db, db, check_db_connection @@ -12,12 +18,106 @@ from app.routes import register_routes +class JsonFormatter(logging.Formatter): + def format(self, record): + payload = { + "ts": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "event": record.getMessage(), + "service": "url-shortener-api", + } + + # Allow route and dependency logs to attach machine-readable fields. + for key in ( + "request_id", + "method", + "path", + "endpoint", + "route", + "status_code", + "duration_ms", + "component", + "error", + "user_id", + "url_id", + "short_code", + "param", + "value", + "resource", + "reason", + "log_level", + ): + if hasattr(record, key): + payload[key] = getattr(record, key) + + return json.dumps(payload, ensure_ascii=True) + + +def _configure_logging(app: Flask) -> None: + level_name = os.environ.get("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + log_file_path = os.environ.get("LOG_FILE_PATH", "logs/app.log") + log_max_bytes = int(os.environ.get("LOG_FILE_MAX_BYTES", str(10 * 1024 * 1024))) + log_backup_count = int(os.environ.get("LOG_FILE_BACKUP_COUNT", "5")) + + formatter = JsonFormatter() + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + + handlers = [stream_handler] + file_logging_error = None + try: + log_dir = os.path.dirname(log_file_path) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + file_handler = RotatingFileHandler( + log_file_path, + maxBytes=log_max_bytes, + backupCount=log_backup_count, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + handlers.append(file_handler) + except OSError as exc: + file_logging_error = str(exc) + + app.logger.handlers.clear() + for handler in handlers: + app.logger.addHandler(handler) + app.logger.setLevel(level) + app.logger.propagate = False + + werkzeug_logger = logging.getLogger("werkzeug") + werkzeug_logger.handlers.clear() + for handler in handlers: + werkzeug_logger.addHandler(handler) + werkzeug_logger.setLevel(level) + werkzeug_logger.propagate = False + + app.logger.info( + "logger_configured", + extra={"endpoint": "app._configure_logging", "log_level": level_name, "resource": log_file_path}, + ) + + if file_logging_error: + app.logger.warning( + "file_logging_disabled", + extra={ + "endpoint": "app._configure_logging", + "resource": log_file_path, + "error": file_logging_error, + }, + ) + + def create_app(): load_dotenv() app = Flask(__name__) app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-me") CORS(app) + _configure_logging(app) init_db(app) init_cache() @@ -26,17 +126,53 @@ def create_app(): try: db.create_tables([User, URL, Event], safe=True) except Exception: + current_app.logger.warning( + "db_create_tables_skipped", + extra={"component": "db", "endpoint": "app.create_app", "reason": "tables_already_exist_or_race"}, + ) pass # Tables already created by another instance db.close() register_routes(app) + @app.before_request + def _before_request_log_start(): + g.request_start = time.perf_counter() + g.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + + @app.after_request + def _after_request_log(response): + start = getattr(g, "request_start", None) + elapsed_ms = (time.perf_counter() - start) * 1000 if start else 0 + app.logger.info( + "request_completed", + extra={ + "request_id": getattr(g, "request_id", ""), + "method": request.method, + "path": request.path, + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", + "status_code": response.status_code, + "duration_ms": round(elapsed_ms, 2), + }, + ) + return response + def _dependency_status(): """Check DB and Redis. Returns (db_status, cache_status).""" try: check_db_connection() db_status = "ok" except Exception as e: + current_app.logger.error( + "dependency_check_failed", + extra={ + "component": "db", + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", + "error": str(e), + }, + ) db_status = str(e) from app.cache import get_cache @@ -46,6 +182,15 @@ def _dependency_status(): cache.ping() cache_status = "ok" except Exception as e: + current_app.logger.error( + "dependency_check_failed", + extra={ + "component": "cache", + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", + "error": str(e), + }, + ) cache_status = str(e) return db_status, cache_status @@ -79,14 +224,47 @@ def health(): @app.errorhandler(404) def not_found(e): + app.logger.warning( + "http_not_found", + extra={ + "request_id": getattr(g, "request_id", ""), + "method": request.method, + "path": request.path, + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", + "status_code": 404, + }, + ) return jsonify(error="not found"), 404 @app.errorhandler(405) def method_not_allowed(e): + app.logger.warning( + "http_method_not_allowed", + extra={ + "request_id": getattr(g, "request_id", ""), + "method": request.method, + "path": request.path, + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", + "status_code": 405, + }, + ) return jsonify(error="method not allowed"), 405 @app.errorhandler(500) def internal_error(e): + app.logger.exception( + "http_internal_error", + extra={ + "request_id": getattr(g, "request_id", ""), + "method": request.method, + "path": request.path, + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", + "status_code": 500, + }, + ) return jsonify(error="internal server error"), 500 return app diff --git a/app/routes/auth.py b/app/routes/auth.py index b7f35a4d..be5f3ab4 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -46,6 +46,11 @@ def register(): created_at=datetime.utcnow(), ) + current_app.logger.info( + "user_registered", + extra={"component": "auth", "endpoint": "auth.register", "user_id": user.id, "value": email}, + ) + return jsonify( session_token=_make_session_token(user.id), user={"id": user.id, "email": user.email}, diff --git a/app/routes/events.py b/app/routes/events.py index e37c98e0..58bf343d 100644 --- a/app/routes/events.py +++ b/app/routes/events.py @@ -3,7 +3,7 @@ import os from datetime import datetime -from flask import Blueprint, jsonify, request +from flask import current_app, Blueprint, jsonify, request from app.cache import cache_get, cache_set, cache_delete_pattern from app.database import db @@ -33,7 +33,7 @@ def list_events(): user_id = request.args.get("user_id") event_type = request.args.get("event_type") - cache_key = f"events:list:{url_id}:{user_id}:{event_type}" + cache_key = "events:list:" + str(url_id) + ":" + str(user_id) + ":" + str(event_type) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -44,12 +44,20 @@ def list_events(): try: query = query.where(Event.url == int(url_id)) except (ValueError, TypeError): + current_app.logger.warning( + "invalid_filter", + extra={"component": "events", "endpoint": "events.list_events", "param": "url_id", "value": str(url_id)}, + ) return jsonify(error="url_id must be an integer"), 400 if user_id is not None: try: query = query.where(Event.user == int(user_id)) except (ValueError, TypeError): + current_app.logger.warning( + "invalid_filter", + extra={"component": "events", "endpoint": "events.list_events", "param": "user_id", "value": str(user_id)}, + ) return jsonify(error="user_id must be an integer"), 400 if event_type is not None: @@ -58,6 +66,10 @@ def list_events(): try: limit = int(request.args.get("limit", 100)) except (ValueError, TypeError): + current_app.logger.warning( + "invalid_limit_parameter", + extra={"component": "events", "endpoint": "events.list_events", "param": "limit", "value": str(request.args.get("limit"))}, + ) limit = 100 query = query.limit(min(limit, 500)) @@ -76,7 +88,11 @@ def load_events_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - return jsonify(error=f"{filename} not found"), 404 + current_app.logger.error( + "file_not_found", + extra={"component": "events", "endpoint": "events.load_events_csv", "resource": filepath}, + ) + return jsonify(error=filename + " not found"), 404 allowed = {"id", "url_id", "user_id", "event_type", "timestamp", "details"} now = str(datetime.utcnow()) @@ -87,6 +103,10 @@ def load_events_csv(): try: entry["details"] = json.loads(entry["details"]) except (ValueError, TypeError): + current_app.logger.warning( + "invalid_event_details_format", + extra={"component": "events", "endpoint": "events.load_events_csv", "value": str(row)}, + ) entry["details"] = None entry.setdefault("timestamp", now) cleaned.append(entry) diff --git a/app/routes/links.py b/app/routes/links.py index 07387d40..3d4a1202 100644 --- a/app/routes/links.py +++ b/app/routes/links.py @@ -3,7 +3,7 @@ from urllib.parse import urlparse import base62 -from flask import Blueprint, jsonify, request +from flask import current_app, Blueprint, jsonify, request from app.cache import get_cache, cache_delete_pattern from app.models.event import Event @@ -21,6 +21,10 @@ def _valid_url(url: str) -> bool: parsed = urlparse(url) return parsed.scheme in ("http", "https") and bool(parsed.netloc) except Exception: + current_app.logger.warning( + "invalid_url_format", + extra={"component": "links", "endpoint": "links._valid_url", "param": "url", "value": url}, + ) return False @@ -35,7 +39,10 @@ def _log_event(url_id, user_id, event_type, details): ) cache_delete_pattern("events:list:*") except Exception: - pass + current_app.logger.error( + "event_logging_failed", + extra={"component": "links", "endpoint": "links._log_event", "value": str(details)}, + ) @links_bp.route("/shorten", methods=["POST"]) @@ -55,12 +62,14 @@ def shorten(): if existing: return jsonify( short_code=existing.short_code, - short_url=f"{request.host_url}{existing.short_code}", + short_url=request.host_url + existing.short_code, original_url=existing.original_url, title=existing.title, ) short_code = _generate_short_code() + + # ! should add measure to prevent infinite loop while URL.select().where(URL.short_code == short_code).exists(): short_code = _generate_short_code() @@ -78,7 +87,7 @@ def shorten(): return jsonify( short_code=short_code, - short_url=f"{request.host_url}{short_code}", + short_url=request.host_url + short_code, original_url=original_url, title=title, ), 201 @@ -91,6 +100,15 @@ def list_links(): page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 20)) except (ValueError, TypeError): + current_app.logger.warning( + "invalid_pagination_parameters", + extra={ + "component": "links", + "endpoint": "links.list_links", + "param": "page_or_per_page", + "value": request.args.get("page") or request.args.get("per_page"), + }, + ) return jsonify(error="page and per_page must be integers"), 400 query = URL.select().where(URL.is_active).order_by(URL.created_at.desc()) @@ -168,9 +186,12 @@ def update_link(code): cache = get_cache() if cache: try: - cache.delete(f"url:{code}") + cache.delete("url:" + code) except Exception: - pass + current_app.logger.error( + "cache_delete_failed", + extra={"component": "cache", "endpoint": "links.update_link", "short_code": code}, + ) _log_event(url.id, None, "updated", {"old_url": old_url, "new_url": url.original_url}) @@ -196,9 +217,12 @@ def delete_link(code): cache = get_cache() if cache: try: - cache.delete(f"url:{code}") + cache.delete("url:" + code) except Exception: - pass + current_app.logger.error( + "cache_delete_failed", + extra={"component": "cache", "endpoint": "links.delete_link", "short_code": code}, + ) _log_event(url.id, None, "deleted", {"short_code": code}) diff --git a/app/routes/redirect.py b/app/routes/redirect.py index 78112fb0..d63fa6bb 100644 --- a/app/routes/redirect.py +++ b/app/routes/redirect.py @@ -1,7 +1,7 @@ import json from datetime import datetime -from flask import Blueprint, jsonify, redirect, request +from flask import Blueprint, current_app, jsonify, redirect, request from app.cache import get_cache, cache_delete_pattern from app.models.event import Event @@ -24,6 +24,10 @@ def _log_click(url_id, details): ) cache_delete_pattern("events:list:*") except Exception: + current_app.logger.error( + "click_event_logging_failed", + extra={"component": "redirect", "endpoint": "redirect._log_click", "value": str(details)}, + ) pass @@ -51,7 +55,10 @@ def follow(code): _log_click(cached["id"], details) return redirect(cached["original_url"], code=302) except Exception: - pass + current_app.logger.error( + "cache_fetch_failed", + extra={"component": "cache", "endpoint": "redirect.follow", "short_code": code}, + ) url = URL.get_or_none(URL.short_code == code, URL.is_active) if not url: @@ -61,7 +68,10 @@ def follow(code): try: cache.set(f"url:{code}", json.dumps({"id": url.id, "original_url": url.original_url, "is_active": True}), ex=CACHE_TTL) except Exception: - pass + current_app.logger.error( + "cache_set_failed", + extra={"component": "cache", "endpoint": "redirect.follow", "short_code": code}, + ) _log_click(url.id, details) return redirect(url.original_url, code=302) diff --git a/app/routes/urls.py b/app/routes/urls.py index 3f4d61eb..49781716 100644 --- a/app/routes/urls.py +++ b/app/routes/urls.py @@ -5,7 +5,7 @@ from datetime import datetime import base62 -from flask import Blueprint, jsonify, redirect, request +from flask import Blueprint, redirect, current_app, jsonify, request from app.cache import cache_get, cache_set, cache_delete, cache_delete_pattern from app.database import db @@ -29,7 +29,10 @@ def _log_event(url_id, user_id, event_type, details): ) cache_delete_pattern("events:list:*") except Exception: - pass + current_app.logger.error( + "event_logging_failed", + extra={"component": "urls", "endpoint": "urls._log_event", "value": str(details)}, + ) def _generate_short_code(length: int = 7) -> str: @@ -54,7 +57,7 @@ def list_urls(): user_id = request.args.get("user_id") is_active_str = request.args.get("is_active") - cache_key = f"urls:list:{user_id}:{is_active_str}" + cache_key = "urls:list:" + str(user_id) + ":" + str(is_active_str) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -65,6 +68,10 @@ def list_urls(): try: query = query.where(URL.user == int(user_id)) except (ValueError, TypeError): + current_app.logger.warning( + "invalid_user_id_parameter", + extra={"component": "urls", "endpoint": "urls.list_urls", "param": "user_id", "value": str(user_id)}, + ) return jsonify(error="user_id must be an integer"), 400 if is_active_str is not None: @@ -73,6 +80,10 @@ def list_urls(): try: limit = int(request.args.get("limit", 100)) except (ValueError, TypeError): + current_app.logger.warning( + "invalid_limit_parameter", + extra={"component": "urls", "endpoint": "urls.list_urls", "param": "limit", "value": str(request.args.get("limit"))}, + ) limit = 100 query = query.limit(min(limit, 500)) @@ -91,7 +102,11 @@ def load_urls_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - return jsonify(error=f"{filename} not found"), 404 + current_app.logger.error( + "file_not_found", + extra={"component": "urls", "endpoint": "urls.load_urls_csv", "resource": filepath}, + ) + return jsonify(error=filename + " not found"), 404 allowed = {"id", "user_id", "short_code", "original_url", "title", "is_active", "created_at", "updated_at"} now = str(datetime.utcnow()) @@ -160,7 +175,7 @@ def redirect_by_short_code(short_code): @urls_bp.route("/", methods=["GET"]) def get_url(url_id): - cache_key = f"urls:{url_id}" + cache_key = "urls:" + str(url_id) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -189,7 +204,7 @@ def update_url(url_id): url.updated_at = datetime.utcnow() url.save() - cache_delete(f"urls:{url_id}") + cache_delete("urls:" + str(url_id)) cache_delete_pattern("urls:list:*") return jsonify(_url_dict(url)) @@ -203,7 +218,7 @@ def delete_url(url_id): # Delete dependent events first (FK constraint) Event.delete().where(Event.url == url_id).execute() url.delete_instance() - cache_delete(f"urls:{url_id}") + cache_delete("urls:" + str(url_id)) cache_delete_pattern("urls:list:*") cache_delete_pattern("events:list:*") return jsonify(message="deleted"), 200 diff --git a/app/routes/users.py b/app/routes/users.py index 29fc37f3..228c4724 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -2,7 +2,7 @@ import os from datetime import datetime -from flask import Blueprint, jsonify, request +from flask import Blueprint, current_app, jsonify, request from app.cache import cache_get, cache_set, cache_delete, cache_delete_pattern from app.database import db @@ -10,7 +10,9 @@ from app.models.url import URL from app.models.user import User -_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_PROJECT_ROOT = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) users_bp = Blueprint("users", __name__, url_prefix="/users") @@ -31,9 +33,18 @@ def get_users_list(): page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 20)) except (ValueError, TypeError): + current_app.logger.warning( + "invalid_pagination_parameters", + extra={ + "component": "users", + "endpoint": "users.get_users_list", + "param": "page_or_per_page", + "value": request.args.get("page") or request.args.get("per_page"), + }, + ) return jsonify(error="page and per_page must be integers"), 400 - cache_key = f"users:list:{page}:{per_page}" + cache_key = "users:list:" + str(page) + ":" + str(per_page) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -56,7 +67,11 @@ def load_users_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - return jsonify(error=f"{filename} not found"), 404 + current_app.logger.error( + "file_not_found", + extra={"component": "users", "endpoint": "users.load_users_csv", "resource": filepath}, + ) + return jsonify(error=filename + " not found"), 404 allowed = {"id", "email", "username", "password_hash", "created_at", "updated_at"} now = str(datetime.utcnow()) @@ -70,7 +85,7 @@ def load_users_csv(): with db.atomic(): for i in range(0, len(cleaned), 100): - User.insert_many(cleaned[i:i + 100]).on_conflict_ignore().execute() + User.insert_many(cleaned[i : i + 100]).on_conflict_ignore().execute() db.execute_sql("SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));") @@ -79,7 +94,7 @@ def load_users_csv(): @users_bp.route("/", methods=["GET"]) def get_user(user_id): - cache_key = f"users:{user_id}" + cache_key = "users:" + str(user_id) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -137,7 +152,7 @@ def update_user(user_id): user.updated_at = datetime.utcnow() user.save() - cache_delete(f"users:{user_id}") + cache_delete("users:" + str(user_id)) cache_delete_pattern("users:list:*") return jsonify(_user_dict(user)) @@ -156,7 +171,7 @@ def delete_user(user_id): # Now safe to delete URLs and user URL.delete().where(URL.user == user_id).execute() user.delete_instance() - cache_delete(f"users:{user_id}") + cache_delete("users:" + str(user_id)) cache_delete_pattern("users:list:*") cache_delete_pattern("urls:list:*") cache_delete_pattern("events:list:*") diff --git a/docker-compose.tier2.yml b/docker-compose.tier2.yml index 2e7af54c..95259611 100644 --- a/docker-compose.tier2.yml +++ b/docker-compose.tier2.yml @@ -33,6 +33,9 @@ services: DATABASE_PASSWORD: postgres REDIS_URL: redis://redis:6379 SECRET_KEY: random_secret_key + LOG_FILE_PATH: ${LOG_FILE_PATH_APP_1:-/app/logs/app-1.log} + volumes: + - ./logs/app-1:/app/logs depends_on: db: condition: service_healthy @@ -56,6 +59,9 @@ services: DATABASE_PASSWORD: postgres REDIS_URL: redis://redis:6379 SECRET_KEY: random_secret_key + LOG_FILE_PATH: ${LOG_FILE_PATH_APP_2:-/app/logs/app-2.log} + volumes: + - ./logs/app-2:/app/logs depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 453e1ae7..83c26a5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +name: url-shortner services: db: image: postgres:16 @@ -28,6 +29,9 @@ services: ports: - "5000:5000" environment: + FLASK_DEBUG: "false" + FLASK_HOST: 0.0.0.0 + FLASK_PORT: "5000" DATABASE_NAME: hackathon_db DATABASE_HOST: db DATABASE_PORT: "5432" @@ -35,12 +39,99 @@ services: DATABASE_PASSWORD: postgres REDIS_URL: redis://redis:6379 SECRET_KEY: random_secret_key + LOG_FILE_PATH: ${LOG_FILE_PATH:-/app/logs/app.log} + volumes: + - ./logs/app:/app/logs depends_on: db: condition: service_healthy redis: condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health/live')"] + interval: 15s + timeout: 5s + retries: 5 restart: always + frontend: + build: + context: ./frontend + dockerfile: docker/Dockerfile + ports: + - "3000:3000" + + prometheus: + image: prom/prometheus:latest + ports: + - 9090:9090 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + + node-exporter: + image: prom/node-exporter:latest + command: + - --no-collector.kernel_hung + ports: + - 9100:9100 + + process-exporter: + image: ncabatoff/process-exporter:latest + pid: host + command: + - --config.path + - /etc/process-exporter/config.yml + volumes: + - ./prometheus/process-exporter.yml:/etc/process-exporter/config.yml:ro + ports: + - 9256:9256 + + otel: + image: otel/opentelemetry-collector-contrib:latest + volumes: + - ./otel/config.yaml:/etc/otelcol-contrib/config.yaml + ports: + - 8889:8889 + - 4318:4318 + + loki: + image: grafana/loki:3.0.0 + command: -config.file=/etc/loki/config.yml + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + + promtail: + image: grafana/promtail:3.0.0 + command: -config.file=/etc/promtail/config.yml + depends_on: + - loki + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/run/docker.sock:/var/run/docker.sock + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - promtail-data:/tmp + + grafana: + image: grafana/grafana:latest + depends_on: + - prometheus + - loki + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + volumes: + grafana-data: postgres_data: + loki-data: + promtail-data: diff --git a/frontend/docker/Dockerfile b/frontend/docker/Dockerfile new file mode 100644 index 00000000..165f1e33 --- /dev/null +++ b/frontend/docker/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine AS deps +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +FROM node:20-alpine AS build +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +COPY --from=build /app/public ./public +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa308..68a6c64d 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/grafana/dashboards/overview.json b/grafana/dashboards/overview.json new file mode 100644 index 00000000..8c8c8b09 --- /dev/null +++ b/grafana/dashboards/overview.json @@ -0,0 +1,101 @@ +{ + "uid": "meta-observability-overview", + "title": "Meta Observability Overview", + "schemaVersion": 38, + "version": 1, + "refresh": "30s", + "time": { + "from": "now-6h", + "to": "now" + }, +"panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Node CPU Busy %", + "gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "100 * (1 - avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Named Process Groups", + "gridPos": { "x": 12, "y": 0, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "namedprocess_namegroup_num_procs" + } + ] + }, + { + "id": 3, + "type": "timeseries", + "title": "Process Memory RSS (bytes)", + "gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "namedprocess_namegroup_memory_bytes{memtype=\"resident\"}", + "legendFormat": "{{groupname}} - RSS" + }, + { + "refId": "B", + "expr": "namedprocess_namegroup_memory_bytes{memtype=\"swapped\"}", + "legendFormat": "{{groupname}} - Swap" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes" + } + } + }, + { + "id": 4, + "type": "timeseries", + "title": "Process Disk I/O (bytes/sec)", + "gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "rate(namedprocess_namegroup_read_bytes_total[5m])", + "legendFormat": "{{groupname}} - Read" + }, + { + "refId": "B", + "expr": "rate(namedprocess_namegroup_write_bytes_total[5m])", + "legendFormat": "{{groupname}} - Write" + } + ], + "fieldConfig": { + "defaults": { + "unit": "Bps" + } + } + }, + { + "id": 5, + "type": "timeseries", + "title": "Process Thread Count", + "gridPos": { "x": 0, "y": 16, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "namedprocess_namegroup_num_threads", + "legendFormat": "{{groupname}}" + } + ] + } + ] +} diff --git a/grafana/provisioning/dashboards/dashboards.yml b/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..e2f6873c --- /dev/null +++ b/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: Default + orgId: 1 + folder: "" + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards diff --git a/grafana/provisioning/datasources/loki.yml b/grafana/provisioning/datasources/loki.yml new file mode 100644 index 00000000..f0e4946f --- /dev/null +++ b/grafana/provisioning/datasources/loki.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + uid: loki + access: proxy + url: http://loki:3100 + editable: true diff --git a/grafana/provisioning/datasources/prometheus.yml b/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..af749282 --- /dev/null +++ b/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/loki/config.yml b/loki/config.yml new file mode 100644 index 00000000..b09e1101 --- /dev/null +++ b/loki/config.yml @@ -0,0 +1,27 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: info + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + filesystem: + directory: /loki/chunks diff --git a/otel/config.yaml b/otel/config.yaml new file mode 100644 index 00000000..43eab295 --- /dev/null +++ b/otel/config.yaml @@ -0,0 +1,25 @@ +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + +exporters: + prometheus: + endpoint: 0.0.0.0:8889 + debug: + verbosity: detailed + +service: + pipelines: + metrics: + receivers: [otlp] + processors: [batch, memory_limiter] + exporters: [prometheus] + +processors: + memory_limiter: + check_interval: 5s + limit_mib: 4000 + spike_limit_mib: 500 + batch: \ No newline at end of file diff --git a/prometheus/process-exporter.yml b/prometheus/process-exporter.yml new file mode 100644 index 00000000..cad4f485 --- /dev/null +++ b/prometheus/process-exporter.yml @@ -0,0 +1,35 @@ +process_names: + - name: "backend-flask" + comm: + - 'python' + cmdline: + - '.*run\.py.*' + + - name: "frontend" + comm: + - 'next-server (v' + - 'next-server' + + - name: "database-postgres" + cmdline: + - '.*postgres.*' + + - name: "otel-collector" + cmdline: + - '.*otelcol.*' + + - name: "prometheus" + cmdline: + - '.*prometheus.*' + + - name: "grafana" + cmdline: + - '.*grafana.*' + + - name: "loki" + comm: + - 'loki' + + - name: "promtail" + comm: + - 'promtail' diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml new file mode 100644 index 00000000..3bdd34bd --- /dev/null +++ b/prometheus/prometheus.yml @@ -0,0 +1,22 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] # Prometheus server default port + + - job_name: 'otel-collector' + static_configs: + - targets: ['otel:8889'] # Common default for OTel Collector Prometheus exporter + metrics_path: /metrics + + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + metrics_path: /metrics + + - job_name: 'process-exporter' + static_configs: + - targets: ['process-exporter:9256'] + metrics_path: /metrics diff --git a/promtail/config.yml b/promtail/config.yml new file mode 100644 index 00000000..779ebf43 --- /dev/null +++ b/promtail/config.yml @@ -0,0 +1,23 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: container + - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + target_label: service + - target_label: __path__ + replacement: /var/lib/docker/containers/*/*.log diff --git a/run.py b/run.py index 598c2078..4e9872ac 100644 --- a/run.py +++ b/run.py @@ -10,4 +10,7 @@ def test_ui(): return send_from_directory(os.path.dirname(__file__), "test.html") if __name__ == "__main__": - app.run(debug=True) + debug = os.environ.get("FLASK_DEBUG", "false").lower() == "true" + host = os.environ.get("FLASK_HOST", "0.0.0.0") + port = int(os.environ.get("FLASK_PORT", "5000")) + app.run(host=host, port=port, debug=debug) diff --git a/scripts/create_test_user.py b/scripts/create_test_user.py index 15500180..f250fbf7 100644 --- a/scripts/create_test_user.py +++ b/scripts/create_test_user.py @@ -33,7 +33,7 @@ def create_app(): existing = User.get_or_none(User.email == email) if existing: - print(f"Test user already exists: {email}") + print("Test user already exists: " + email) else: User.create( email=email, @@ -41,5 +41,5 @@ def create_app(): ) print("Test user created:") - print(f" Email: {email}") - print(f" Password: {password}") + print(" Email: " + email) + print(" Password: " + password) diff --git a/scripts/init_db.py b/scripts/init_db.py index 8016bf0f..7515ae00 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -28,7 +28,7 @@ def create_tables(): def seed_users(filepath="users.csv"): - print(f"Seeding users from {filepath}...") + print("Seeding users from " + filepath + "...") with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) @@ -37,11 +37,11 @@ def seed_users(filepath="users.csv"): batch = rows[batch_start:batch_start + 100] User.insert_many(batch).on_conflict_ignore().execute() - print(f" {len(rows)} users seeded.") + print(" " + str(len(rows)) + " users seeded.") def seed_urls(filepath="urls.csv"): - print(f"Seeding URLs from {filepath}...") + print("Seeding URLs from " + filepath + "...") with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) @@ -55,11 +55,11 @@ def seed_urls(filepath="urls.csv"): batch = rows[batch_start:batch_start + 100] URL.insert_many(batch).on_conflict_ignore().execute() - print(f" {len(rows)} URLs seeded.") + print(" " + str(len(rows)) + " URLs seeded.") def seed_events(filepath="events.csv"): - print(f"Seeding events from {filepath}...") + print("Seeding events from " + filepath + "...") with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) @@ -77,7 +77,7 @@ def seed_events(filepath="events.csv"): batch = rows[batch_start:batch_start + 100] Event.insert_many(batch).on_conflict_ignore().execute() - print(f" {len(rows)} events seeded.") + print(" " + str(len(rows)) + " events seeded.") def reset_sequences(): diff --git a/tests/test_events.py b/tests/test_events.py index a63cca58..fc45d60f 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -38,7 +38,7 @@ def test_get_events_by_url(client): _create_event(client, url_id1) _create_event(client, url_id1) _create_event(client, url_id2) - r = client.get(f"/events?url_id={url_id1}") + r = client.get("/events?url_id=" + str(url_id1)) assert r.status_code == 200 data = r.get_json() assert len(data) == 3 # 1 auto-logged "created" + 2 manually created @@ -51,7 +51,7 @@ def test_get_events_by_user(client): _create_event(client, url_id, user_id=uid) _create_event(client, url_id, user_id=uid) _create_event(client, url_id, user_id=None) - r = client.get(f"/events?user_id={uid}") + r = client.get("/events?user_id=" + str(uid)) assert r.status_code == 200 data = r.get_json() assert len(data) == 2 diff --git a/tests/test_integration.py b/tests/test_integration.py index 349dfed2..9a079c90 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -83,7 +83,7 @@ def test_shorten_deleted_url_gets_new_code(client): r1 = shorten(client, "https://deleted.example.com") code = r1.get_json()["short_code"] - client.delete(f"/api/links/{code}") + client.delete("/api/links/" + code) r2 = shorten(client, "https://deleted.example.com") assert r2.status_code == 201 @@ -94,16 +94,16 @@ def test_shorten_deleted_url_gets_new_code(client): def test_redirect_valid(client): code = shorten(client, "https://redirect.example.com").get_json()["short_code"] - r = client.get(f"/{code}", follow_redirects=False) + r = client.get("/" + code, follow_redirects=False) assert r.status_code == 302 assert r.headers["Location"] == "https://redirect.example.com" def test_redirect_increments_click_count(client): code = shorten(client, "https://clicks.example.com").get_json()["short_code"] - client.get(f"/{code}", follow_redirects=False) - client.get(f"/{code}", follow_redirects=False) - r = client.get(f"/api/links/{code}") + client.get("/" + code, follow_redirects=False) + client.get("/" + code, follow_redirects=False) + r = client.get("/api/links/" + code) assert r.get_json()["click_count"] == 2 @@ -115,8 +115,8 @@ def test_redirect_nonexistent(client): def test_redirect_deleted_link(client): code = shorten(client, "https://todelete.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.get(f"/{code}", follow_redirects=False) + client.delete("/api/links/" + code) + r = client.get("/" + code, follow_redirects=False) assert r.status_code == 404 @@ -124,7 +124,7 @@ def test_redirect_deleted_link(client): def test_stats_valid(client): code = shorten(client, "https://stats.example.com").get_json()["short_code"] - r = client.get(f"/{code}+") + r = client.get("/" + code + "+") assert r.status_code == 200 data = r.get_json() assert data["short_code"] == code @@ -138,8 +138,8 @@ def test_stats_nonexistent(client): def test_stats_deleted(client): code = shorten(client, "https://statsdel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.get(f"/{code}+") + client.delete("/api/links/" + code) + r = client.get("/" + code + "+") assert r.status_code == 404 @@ -156,7 +156,7 @@ def test_list_links_returns_active(client): def test_list_links_excludes_deleted(client): code = shorten(client, "https://listdel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") + client.delete("/api/links/" + code) r = client.get("/api/links") urls = [link["short_code"] for link in r.get_json()["links"]] assert code not in urls @@ -164,7 +164,7 @@ def test_list_links_excludes_deleted(client): def test_list_links_pagination(client): for i in range(5): - shorten(client, f"https://page{i}.example.com") + shorten(client, "https://page" + str(i) + ".example.com") r = client.get("/api/links?page=1&per_page=2") assert r.status_code == 200 assert len(r.get_json()["links"]) <= 2 @@ -179,7 +179,7 @@ def test_list_links_invalid_page(client): def test_link_stats_valid(client): code = shorten(client, "https://detail.example.com").get_json()["short_code"] - r = client.get(f"/api/links/{code}") + r = client.get("/api/links/" + code) assert r.status_code == 200 data = r.get_json() assert data["short_code"] == code @@ -194,8 +194,8 @@ def test_link_stats_nonexistent(client): def test_link_stats_deleted(client): code = shorten(client, "https://detaildel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.get(f"/api/links/{code}") + client.delete("/api/links/" + code) + r = client.get("/api/links/" + code) assert r.status_code == 404 @@ -203,21 +203,21 @@ def test_link_stats_deleted(client): def test_update_link(client): code = shorten(client, "https://old.example.com").get_json()["short_code"] - r = client.put(f"/api/links/{code}", json={"url": "https://new.example.com"}) + r = client.put("/api/links/" + code, json={"url": "https://new.example.com"}) assert r.status_code == 200 assert r.get_json()["original_url"] == "https://new.example.com" def test_update_link_reflects_on_redirect(client): code = shorten(client, "https://before.example.com").get_json()["short_code"] - client.put(f"/api/links/{code}", json={"url": "https://after.example.com"}) - r = client.get(f"/{code}", follow_redirects=False) + client.put("/api/links/" + code, json={"url": "https://after.example.com"}) + r = client.get("/" + code, follow_redirects=False) assert r.headers["Location"] == "https://after.example.com" def test_update_link_invalid_url(client): code = shorten(client, "https://updatebad.example.com").get_json()["short_code"] - r = client.put(f"/api/links/{code}", json={"url": "not-a-url"}) + r = client.put("/api/links/" + code, json={"url": "not-a-url"}) assert r.status_code == 400 @@ -228,8 +228,8 @@ def test_update_link_nonexistent(client): def test_update_link_deleted(client): code = shorten(client, "https://updatedel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.put(f"/api/links/{code}", json={"url": "https://example.com"}) + client.delete("/api/links/" + code) + r = client.put("/api/links/" + code, json={"url": "https://example.com"}) assert r.status_code == 404 @@ -237,7 +237,7 @@ def test_update_link_deleted(client): def test_delete_link(client): code = shorten(client, "https://todel.example.com").get_json()["short_code"] - r = client.delete(f"/api/links/{code}") + r = client.delete("/api/links/" + code) assert r.status_code == 200 @@ -249,8 +249,8 @@ def test_delete_link_nonexistent(client): def test_delete_link_already_deleted(client): code = shorten(client, "https://deldel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.delete(f"/api/links/{code}") + client.delete("/api/links/" + code) + r = client.delete("/api/links/" + code) assert r.status_code == 404 @@ -385,7 +385,7 @@ def test_redirect_cache_get_exception_falls_through_to_db(client): mock_cache = MagicMock() mock_cache.get.side_effect = Exception("redis error") with patch("app.routes.redirect.get_cache", return_value=mock_cache): - r = client.get(f"/{url_obj.short_code}") + r = client.get("/" + url_obj.short_code) assert r.status_code == 302 @@ -396,7 +396,7 @@ def test_redirect_cache_set_exception_still_redirects(client): mock_cache.get.return_value = None mock_cache.set.side_effect = Exception("redis write error") with patch("app.routes.redirect.get_cache", return_value=mock_cache): - r = client.get(f"/{url_obj.short_code}") + r = client.get("/" + url_obj.short_code) assert r.status_code == 302 @@ -432,7 +432,7 @@ def _mock_generate(length=7): def test_update_link_title_only(client): code = shorten(client, "https://example.com/title-only").get_json()["short_code"] - r = client.put(f"/api/links/{code}", json={"title": "New Title Only"}) + r = client.put("/api/links/" + code, json={"title": "New Title Only"}) assert r.status_code == 200 data = r.get_json() assert data["title"] == "New Title Only" @@ -444,7 +444,7 @@ def test_update_cache_delete_exception_still_succeeds(client): mock_cache = MagicMock() mock_cache.delete.side_effect = Exception("redis delete error") with patch("app.routes.links.get_cache", return_value=mock_cache): - r = client.put(f"/api/links/{code}", json={"url": "https://example.com/updated"}) + r = client.put("/api/links/" + code, json={"url": "https://example.com/updated"}) assert r.status_code == 200 @@ -453,5 +453,5 @@ def test_delete_cache_delete_exception_still_succeeds(client): mock_cache = MagicMock() mock_cache.delete.side_effect = Exception("redis delete error") with patch("app.routes.links.get_cache", return_value=mock_cache): - r = client.delete(f"/api/links/{code}") + r = client.delete("/api/links/" + code) assert r.status_code == 200 diff --git a/tests/test_urls.py b/tests/test_urls.py index 11af4a08..8405ff95 100644 --- a/tests/test_urls.py +++ b/tests/test_urls.py @@ -27,7 +27,7 @@ def test_get_urls_by_user(client): _create_url(client, "https://user1.example.com", user_id=uid) _create_url(client, "https://user2.example.com", user_id=uid) _create_url(client, "https://other.example.com", user_id=None) - r = client.get(f"/urls?user_id={uid}") + r = client.get("/urls?user_id=" + str(uid)) assert r.status_code == 200 data = r.get_json() assert len(data) == 2 @@ -37,7 +37,7 @@ def test_get_urls_by_user(client): def test_get_active_urls(client): _create_url(client, "https://active1.example.com") uid = _create_url(client, "https://todeactivate.example.com").get_json()["id"] - client.put(f"/urls/{uid}", json={"is_active": False}) + client.put("/urls/" + str(uid), json={"is_active": False}) r = client.get("/urls?is_active=true") assert r.status_code == 200 data = r.get_json() @@ -46,7 +46,7 @@ def test_get_active_urls(client): def test_get_inactive_urls(client): uid = _create_url(client, "https://inactive.example.com").get_json()["id"] - client.put(f"/urls/{uid}", json={"is_active": False}) + client.put("/urls/" + str(uid), json={"is_active": False}) r = client.get("/urls?is_active=false") assert r.status_code == 200 data = r.get_json() @@ -80,7 +80,7 @@ def test_create_url_generates_unique_short_codes(client): def test_get_url_by_id(client): url_id = _create_url(client, "https://byid.example.com").get_json()["id"] - r = client.get(f"/urls/{url_id}") + r = client.get("/urls/" + str(url_id)) assert r.status_code == 200 assert r.get_json()["id"] == url_id @@ -94,14 +94,14 @@ def test_get_nonexistent_url(client): def test_update_url_title(client): url_id = _create_url(client, "https://updatetitle.example.com", title="Old").get_json()["id"] - r = client.put(f"/urls/{url_id}", json={"title": "Updated Title"}) + r = client.put("/urls/" + str(url_id), json={"title": "Updated Title"}) assert r.status_code == 200 assert r.get_json()["title"] == "Updated Title" def test_deactivate_url(client): url_id = _create_url(client, "https://deactivate.example.com").get_json()["id"] - r = client.put(f"/urls/{url_id}", json={"is_active": False}) + r = client.put("/urls/" + str(url_id), json={"is_active": False}) assert r.status_code == 200 assert r.get_json()["is_active"] is False @@ -115,14 +115,14 @@ def test_update_nonexistent_url(client): def test_delete_url(client): url_id = _create_url(client, "https://delete.example.com").get_json()["id"] - r = client.delete(f"/urls/{url_id}") + r = client.delete("/urls/" + str(url_id)) assert r.status_code == 200 def test_delete_url_removes_from_db(client): url_id = _create_url(client, "https://gone.example.com").get_json()["id"] - client.delete(f"/urls/{url_id}") - assert client.get(f"/urls/{url_id}").status_code == 404 + client.delete("/urls/" + str(url_id)) + assert client.get("/urls/" + str(url_id)).status_code == 404 def test_delete_nonexistent_url(client): @@ -134,6 +134,6 @@ def test_delete_nonexistent_url(client): def test_redirect_via_short_code(client): short_code = _create_url(client, "https://redirect.example.com").get_json()["short_code"] - r = client.get(f"/{short_code}", follow_redirects=False) + r = client.get("/" + short_code, follow_redirects=False) assert r.status_code == 302 assert r.headers["Location"] == "https://redirect.example.com" diff --git a/tests/test_users.py b/tests/test_users.py index 3e453a9c..7570f866 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -21,7 +21,7 @@ def test_get_users_list_returns_created(client): def test_get_users_pagination(client): for i in range(15): - _create_user(client, f"page{i}@example.com", f"pageuser{i}") + _create_user(client, "page" + str(i) + "@example.com", "pageuser" + str(i)) r = client.get("/users?page=1&per_page=10") assert r.status_code == 200 assert len(r.get_json()) == 10 @@ -29,7 +29,7 @@ def test_get_users_pagination(client): def test_get_users_pagination_page2(client): for i in range(15): - _create_user(client, f"pg2_{i}@example.com", f"pg2user{i}") + _create_user(client, "pg2_" + str(i) + "@example.com", "pg2user" + str(i)) r = client.get("/users?page=2&per_page=10") assert r.status_code == 200 assert len(r.get_json()) == 5 @@ -40,7 +40,7 @@ def test_get_users_pagination_page2(client): def test_get_user_by_id(client): r = _create_user(client, "byid@example.com", "byiduser") user_id = r.get_json()["id"] - r2 = client.get(f"/users/{user_id}") + r2 = client.get("/users/" + str(user_id)) assert r2.status_code == 200 assert r2.get_json()["id"] == user_id @@ -48,7 +48,7 @@ def test_get_user_by_id(client): def test_get_user_by_id_returns_fields(client): r = _create_user(client, "fields@example.com", "fieldsuser") uid = r.get_json()["id"] - data = client.get(f"/users/{uid}").get_json() + data = client.get("/users/" + str(uid)).get_json() assert data["email"] == "fields@example.com" assert data["username"] == "fieldsuser" @@ -85,14 +85,14 @@ def test_create_user_duplicate_email(client): def test_update_user(client): uid = _create_user(client, "upd@example.com", "oldname").get_json()["id"] - r = client.put(f"/users/{uid}", json={"username": "newname"}) + r = client.put("/users/" + str(uid), json={"username": "newname"}) assert r.status_code == 200 assert r.get_json()["username"] == "newname" def test_update_user_email(client): uid = _create_user(client, "oldemail@example.com", "emailuser").get_json()["id"] - r = client.put(f"/users/{uid}", json={"email": "newemail@example.com"}) + r = client.put("/users/" + str(uid), json={"email": "newemail@example.com"}) assert r.status_code == 200 assert r.get_json()["email"] == "newemail@example.com" @@ -106,14 +106,14 @@ def test_update_nonexistent_user(client): def test_delete_user(client): uid = _create_user(client, "del@example.com", "deluser").get_json()["id"] - r = client.delete(f"/users/{uid}") + r = client.delete("/users/" + str(uid)) assert r.status_code == 200 def test_delete_user_removes_from_db(client): uid = _create_user(client, "gone@example.com", "goneuser").get_json()["id"] - client.delete(f"/users/{uid}") - assert client.get(f"/users/{uid}").status_code == 404 + client.delete("/users/" + str(uid)) + assert client.get("/users/" + str(uid)).status_code == 404 def test_delete_nonexistent_user(client):