commit 9f4bf1327602ac09e08de52fb9f23bcf695e14f2 Author: Damir Date: Mon May 11 22:08:50 2026 +0300 init diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..3823c2a --- /dev/null +++ b/backend/.env @@ -0,0 +1,3 @@ +OZON_CLIENT_ID=898289 +OZON_API_KEY=4467d825-d7aa-4804-abd0-9f9d2edef88e +DATABASE_URL=postgresql://devops_portal:ADzXo0Gm!@10.10.10.167:5432/devops_portal diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..a0fadbe --- /dev/null +++ b/backend/app.py @@ -0,0 +1,23 @@ +import os +from flask import Flask +from flask_cors import CORS +from dotenv import load_dotenv + +# Load environment variables from .env +load_dotenv() + +app = Flask(__name__) +CORS(app) + +# Ensure upload folder exists +UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads') +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER + +# Register blueprints (routes) +from .routes.cases import cases_blueprint +app.register_blueprint(cases_blueprint, url_prefix='/api') + +if __name__ == '__main__': + # Use host 0.0.0.0 for Docker compatibility, port 5000 + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..041ccd3 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,19 @@ +# backend/db.py +import os +from psycopg2 import pool +from dotenv import load_dotenv + +load_dotenv() + +DATABASE_URL = os.getenv('DATABASE_URL') + +# Initialise a connection pool (min 1, max 10 connections) +postgres_pool = pool.ThreadedConnectionPool(1, 10, dsn=DATABASE_URL) + +def get_db_connection(): + """Return a connection from the pool. Caller must close it after use.""" + return postgres_pool.getconn() + +def release_db_connection(conn): + """Return the connection to the pool.""" + postgres_pool.putconn(conn) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..83d5899 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.3 +flask-cors==4.0.1 +python-dotenv==1.0.1 +psycopg2-binary==2.9.9 +Werkzeug==3.0.3 diff --git a/backend/routes/cases.py b/backend/routes/cases.py new file mode 100644 index 0000000..01033d1 --- /dev/null +++ b/backend/routes/cases.py @@ -0,0 +1,152 @@ +# backend/routes/cases.py +import os +from flask import Blueprint, request, jsonify, current_app +from werkzeug.utils import secure_filename +from datetime import datetime + +from ..db import get_db_connection, release_db_connection + +cases_blueprint = Blueprint('cases', __name__) + +# Helper to serialize case rows +def serialize_case(row): + return { + "id": row[0], + "violator_url": row[1], + "violator_article": row[2], + "my_article": row[3], + "comment": row[4], + "status": row[5], + "created_at": row[6].isoformat() if row[6] else None, + "updated_at": row[7].isoformat() if row[7] else None, + } + +# ---------- CASE CRUD ---------- +@cases_blueprint.route('/cases', methods=['GET']) +def list_cases(): + # optional filters: ?article=...&status=... + article = request.args.get('article') + status = request.args.get('status') + conn = get_db_connection() + cur = conn.cursor() + query = "SELECT * FROM cases" + params = [] + conditions = [] + if article: + conditions.append("violator_article = %s") + params.append(article) + if status: + conditions.append("status = %s") + params.append(status) + if conditions: + query += " WHERE " + " AND ".join(conditions) + cur.execute(query, params) + rows = cur.fetchall() + release_db_connection(conn) + return jsonify([serialize_case(r) for r in rows]) + +@cases_blueprint.route('/cases', methods=['POST']) +def create_case(): + data = request.json + required = ["violator_url", "violator_article"] + for f in required: + if f not in data: + return jsonify({"error": f"Missing field {f}"}), 400 + conn = get_db_connection() + cur = conn.cursor() + cur.execute( + """INSERT INTO cases (violator_url, violator_article, my_article, comment, status) + VALUES (%s, %s, %s, %s, %s) RETURNING id""", + ( + data.get('violator_url'), + data.get('violator_article'), + data.get('my_article'), + data.get('comment'), + data.get('status', 'new') + ) + ) + case_id = cur.fetchone()[0] + conn.commit() + release_db_connection(conn) + return jsonify({"id": case_id}), 201 + +@cases_blueprint.route('/cases/', methods=['GET']) +def get_case(case_id): + conn = get_db_connection() + cur = conn.cursor() + cur.execute("SELECT * FROM cases WHERE id = %s", (case_id,)) + row = cur.fetchone() + release_db_connection(conn) + if not row: + return jsonify({"error": "Case not found"}), 404 + return jsonify(serialize_case(row)) + +@cases_blueprint.route('/cases/', methods=['PUT']) +def update_case(case_id): + data = request.json + fields = [] + params = [] + allowed = ["violator_url", "violator_article", "my_article", "comment", "status"] + for f in allowed: + if f in data: + fields.append(f"{f} = %s") + params.append(data[f]) + if not fields: + return jsonify({"error": "No updatable fields provided"}), 400 + params.append(case_id) + conn = get_db_connection() + cur = conn.cursor() + cur.execute(f"UPDATE cases SET {', '.join(fields)}, updated_at = %s WHERE id = %s", + (*params[:-1], datetime.utcnow(), case_id)) + conn.commit() + release_db_connection(conn) + return jsonify({"message": "Case updated"}) + +@cases_blueprint.route('/cases/', methods=['DELETE']) +def delete_case(case_id): + conn = get_db_connection() + cur = conn.cursor() + cur.execute("DELETE FROM cases WHERE id = %s", (case_id,)) + conn.commit() + release_db_connection(conn) + return jsonify({"message": "Case deleted"}) + +# ---------- FILE UPLOAD ---------- +@cases_blueprint.route('/cases//files', methods=['POST']) +def upload_files(case_id): + if 'files' not in request.files: + return jsonify({"error": "No files part in request"}), 400 + files = request.files.getlist('files') + saved_paths = [] + upload_folder = current_app.config['UPLOAD_FOLDER'] + conn = get_db_connection() + cur = conn.cursor() + for f in files: + if f.filename == '': + continue + filename = secure_filename(f.filename) + timestamp = datetime.utcnow().strftime('%Y%m%d%H%M%S%f') + unique_name = f"{timestamp}_{filename}" + file_path = os.path.join(upload_folder, unique_name) + f.save(file_path) + # store relative path in DB + rel_path = os.path.relpath(file_path, start=os.path.dirname(os.path.abspath(__file__))) + cur.execute( + "INSERT INTO files (case_id, file_path) VALUES (%s, %s) RETURNING id", + (case_id, rel_path) + ) + saved_paths.append(rel_path) + conn.commit() + release_db_connection(conn) + return jsonify({"files": saved_paths}), 201 + +# Get files for a case +@cases_blueprint.route('/cases//files', methods=['GET']) +def list_files(case_id): + conn = get_db_connection() + cur = conn.cursor() + cur.execute("SELECT id, file_path, uploaded_at FROM files WHERE case_id = %s", (case_id,)) + rows = cur.fetchall() + release_db_connection(conn) + files = [{"id": r[0], "path": r[1], "uploaded_at": r[2].isoformat()} for r in rows] + return jsonify(files) diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..d90eabf --- /dev/null +++ b/db/init.sql @@ -0,0 +1,19 @@ +-- db/init.sql +-- Создаёт таблицы для хранения кейсов и файлов +CREATE TABLE IF NOT EXISTS cases ( + id SERIAL PRIMARY KEY, + violator_url TEXT NOT NULL, + violator_article VARCHAR(50) NOT NULL, + my_article VARCHAR(50), + comment TEXT, + status VARCHAR(20) DEFAULT 'new', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS files ( + id SERIAL PRIMARY KEY, + case_id INT REFERENCES cases(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/frontend/css/styles.css b/frontend/css/styles.css new file mode 100644 index 0000000..fa8885d --- /dev/null +++ b/frontend/css/styles.css @@ -0,0 +1,164 @@ +/* frontend/css/styles.css */ +/* Premium dark‑mode + glassmorphism design */ + +:root { + --font-family: 'Inter', sans-serif; + --color-bg: hsl(220, 10%, 10%); + --color-surface: hsla(220, 10%, 15%, 0.8); + --color-primary: hsl(210, 80%, 55%); + --color-primary-hover: hsl(210, 80%, 45%); + --color-success: hsl(140, 60%, 45%); + --color-error: hsl(0, 70%, 50%); + --color-text: hsl(0, 0%, 90%); + --radius: 0.75rem; + --shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-family); + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + text-align: center; + padding: 2rem 1rem; + background: linear-gradient(135deg, hsla(210, 70%, 55%, 0.6), hsla(210, 70%, 35%, 0.6)); + backdrop-filter: blur(12px); + box-shadow: var(--shadow); +} + +.header h1 { + font-size: 2.2rem; + margin-bottom: 0.5rem; + color: var(--color-primary); +} + +.container { + flex: 1; + padding: 1.5rem; + max-width: 1200px; + margin: 0 auto; +} + +.section { + margin-bottom: 2rem; +} + +h2 { + margin-bottom: 1rem; + font-weight: 600; + color: var(--color-primary); +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.controls input, +.controls select { + padding: 0.5rem 0.75rem; + border: 1px solid hsla(0, 0%, 100%, 0.1); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); +} + +.btn-primary { + background: var(--color-primary); + border: none; + color: #fff; + padding: 0.5rem 1rem; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s; +} + +.btn-primary:hover { + background: var(--color-primary-hover); +} + +.btn-secondary { + background: hsla(0, 0%, 100%, 0.1); + border: none; + color: var(--color-text); + padding: 0.5rem 1rem; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s; +} + +.btn-secondary:hover { + background: hsla(0, 0%, 100%, 0.2); +} + +.table { + width: 100%; + border-collapse: collapse; + backdrop-filter: blur(6px); + background: var(--color-surface); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow); +} + +.table th, +.table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid hsla(0, 0%, 100%, 0.05); +} + +.table th { + background: hsla(0, 0%, 100%, 0.05); + color: var(--color-primary); +} + +.hidden { display: none; } + +label { + display: block; + margin-bottom: 0.75rem; + color: var(--color-text); +} + +label input, +label textarea { + width: 100%; + padding: 0.5rem; + margin-top: 0.25rem; + border: 1px solid hsla(0,0%,100%,0.1); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); +} + +.preview { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.preview img { + max-width: 120px; + max-height: 120px; + object-fit: cover; + border-radius: var(--radius); + box-shadow: var(--shadow); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d4f2dd3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,89 @@ + + + + + + OzonGuard – подготовка жалоб + + + + +
+

OzonGuard

+

Быстро создавайте и отправляйте жалобы в поддержку Ozon

+
+ +
+ +
+

Список кейсов

+
+ + + +
+ + + + + + + + + + + +
IDАртикулСтатусСозданДействия
+
+ + + + + + +
+ + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..5c8d069 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,54 @@ +// frontend/js/api.js +// Base URL for backend API (adjust if using different host/port) +const API_BASE = "http://localhost:5000/api"; + +export async function fetchCases(params = {}) { + const query = new URLSearchParams(params).toString(); + const res = await fetch(`${API_BASE}/cases${query ? `?${query}` : ''}`); + return await res.json(); +} + +export async function createCase(data) { + const res = await fetch(`${API_BASE}/cases`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return await res.json(); +} + +export async function getCase(id) { + const res = await fetch(`${API_BASE}/cases/${id}`); + return await res.json(); +} + +export async function updateCase(id, data) { + const res = await fetch(`${API_BASE}/cases/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return await res.json(); +} + +export async function deleteCase(id) { + const res = await fetch(`${API_BASE}/cases/${id}`, { method: "DELETE" }); + return await res.json(); +} + +export async function uploadFiles(caseId, fileList) { + const form = new FormData(); + for (const f of fileList) { + form.append("files", f); + } + const res = await fetch(`${API_BASE}/cases/${caseId}/files`, { + method: "POST", + body: form, + }); + return await res.json(); +} + +export async function listFiles(caseId) { + const res = await fetch(`${API_BASE}/cases/${caseId}/files`); + return await res.json(); +} diff --git a/init.sh b/init.sh new file mode 100644 index 0000000..fadc5cf --- /dev/null +++ b/init.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# init.sh - Инициализация проекта OzonGuard на Ubuntu Linux +# --------------------------------------------------------- +# 1️⃣ Проверка наличия Python +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: Python3 не найден в PATH. Установите Python 3.9+ и повторите." >&2 + exit 1 +fi + +# 2️⃣ Создание виртуального окружения +VENV_DIR="venv" +if [ ! -d "$VENV_DIR" ]; then + python3 -m venv "$VENV_DIR" + echo "Виртуальное окружение создано в $VENV_DIR" +else + echo "Виртуальное окружение уже существует" +fi + +# 3️⃣ Активация venv +# shellcheck source=/dev/null +source "$VENV_DIR/bin/activate" +if [ $? -ne 0 ]; then + echo "Error: Не удалось активировать виртуальное окружение" >&2 + exit 1 +fi + +echo "Виртуальное окружение активировано" + +# 4️⃣ Установка зависимостей backend +REQ_FILE="backend/requirements.txt" +if [ -f "$REQ_FILE" ]; then + pip install -r "$REQ_FILE" + echo "Зависимости backend установлены" +else + echo "Warning: requirements.txt не найден, пропускаем установку backend зависимостей" +fi + +# 5️⃣ Инициализация базы данных (если установлен psql) +if command -v psql >/dev/null 2>&1; then + INIT_SQL="db/init.sql" + if [ -f "$INIT_SQL" ]; then + ENV_FILE="backend/.env" + if [ -f "$ENV_FILE" ]; then + # Читаем DATABASE_URL из .env + DB_URL=$(grep '^DATABASE_URL=' "$ENV_FILE" | cut -d'=' -f2-) + if [ -n "$DB_URL" ]; then + # Преобразуем URL вида postgresql://user:pass@host:port/db + # Используем pgsql параметр -d -h -p -U + # Сохраняем пароль в переменной окружения PGPASSWORD + URI=$(python3 -c "import sys, urllib.parse as u; print(u.urlparse('$DB_URL')._replace(scheme=''))." ) + # Extract components using awk (fallback if python not available) + USER=$(echo "$DB_URL" | sed -E 's|postgresql://([^:]+):([^@]+)@.*|\1|') + PASS=$(echo "$DB_URL" | sed -E 's|postgresql://([^:]+):([^@]+)@.*|\2|') + HOST=$(echo "$DB_URL" | sed -E 's|postgresql://[^@]+@([^:/]+).*|\1|') + PORT=$(echo "$DB_URL" | sed -E 's|postgresql://[^@]+@[^:]+:([0-9]+).*|\1|') + DBNAME=$(echo "$DB_URL" | sed -E 's|.*/([^?]+).*|\1|') + export PGPASSWORD="$PASS" + echo "Инициализируем базу данных..." + psql -h "$HOST" -p "${PORT:-5432}" -U "$USER" -d "$DBNAME" -f "$INIT_SQL" + echo "База данных инициализирована" + unset PGPASSWORD + else + echo "Warning: DATABASE_URL не найден в .env, пропускаем инициализацию БД" + fi + else + echo "Warning: .env файл не найден, пропускаем инициализацию БД" + fi + else + echo "Warning: init.sql не найден, пропускаем инициализацию БД" + fi +else + echo "Warning: psql не найден в PATH. Запустите 'psql -f db/init.sql' вручную, используя параметры из .env." +fi + +# 6️⃣ Запуск Flask сервера +echo "Запуск Flask сервера..." +python3 backend/app.py diff --git a/package.json b/package.json new file mode 100644 index 0000000..33b2f96 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "ozon", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +}