add article and pubications

This commit is contained in:
2026-06-25 10:30:24 +03:00
parent 7fa8a9b7fc
commit 7206a0a0c5
25 changed files with 1180 additions and 86 deletions

View File

@@ -8,6 +8,7 @@ from routes.documents import documents_bp
from routes.users import users_bp
from routes.payments import payments_bp
from routes.subscriptions import subscriptions_bp
from routes.articles import articles_bp
def create_app(test_config=None):
# create and configure the app
@@ -50,6 +51,7 @@ def create_app(test_config=None):
app.register_blueprint(users_bp, url_prefix="/users")
app.register_blueprint(payments_bp, url_prefix="/payments")
app.register_blueprint(subscriptions_bp, url_prefix="/subscriptions")
app.register_blueprint(articles_bp, url_prefix="/articles")
return app

Binary file not shown.

View File

@@ -0,0 +1,146 @@
import sqlite3
from dataclasses import dataclass
from typing import Optional, List
@dataclass
class ArticleModel:
id: Optional[int] = None
title: Optional[str] = None
content: Optional[str] = None
author_id: Optional[int] = None
author_name: Optional[str] = None # To hold joint first_name + last_name
created_at: Optional[str] = None
class Articles:
def __init__(self, db_path="instance/app_database.db"):
self.db_path = db_path
self._create_articles_table()
def _create_articles_table(self):
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES users (id)
);
"""
)
conn.commit()
def add_article(self, title: str, content: str, author_id: int) -> Optional[int]:
"""Insert a new article and return its ID."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO articles (title, content, author_id)
VALUES (?, ?, ?)
""",
(title, content, author_id),
)
conn.commit()
return cursor.lastrowid
except sqlite3.Error as e:
print(f"Error adding article: {e}")
return None
def get_article(self, article_id: int) -> Optional[ArticleModel]:
"""Fetch a single article by ID, joining users to get the author's name."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT a.id, a.title, a.content, a.author_id, u.first_name, u.last_name, a.created_at
FROM articles a
JOIN users u ON a.author_id = u.id
WHERE a.id = ?
""",
(article_id,),
)
row = cursor.fetchone()
if not row:
return None
author_name = f"{row[4] or ''} {row[5] or ''}".strip() or "Autor necunoscut"
return ArticleModel(
id=row[0],
title=row[1],
content=row[2],
author_id=row[3],
author_name=author_name,
created_at=row[6],
)
except sqlite3.Error as e:
print(f"Error getting article: {e}")
return None
def get_all_articles(self) -> List[ArticleModel]:
"""Fetch all articles ordered by created_at DESC (newest first)."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT a.id, a.title, a.content, a.author_id, u.first_name, u.last_name, a.created_at
FROM articles a
JOIN users u ON a.author_id = u.id
ORDER BY a.created_at DESC
"""
)
rows = cursor.fetchall()
articles = []
for row in rows:
author_name = f"{row[4] or ''} {row[5] or ''}".strip() or "Autor necunoscut"
articles.append(
ArticleModel(
id=row[0],
title=row[1],
content=row[2],
author_id=row[3],
author_name=author_name,
created_at=row[6],
)
)
return articles
except sqlite3.Error as e:
print(f"Error fetching all articles: {e}")
return []
def update_article(self, article_id: int, title: str, content: str) -> bool:
"""Update the title and content of an article."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE articles
SET title = ?, content = ?
WHERE id = ?
""",
(title, content, article_id),
)
conn.commit()
return cursor.rowcount > 0
except sqlite3.Error as e:
print(f"Error updating article: {e}")
return False
def delete_article(self, article_id: int) -> bool:
"""Delete an article by ID."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM articles WHERE id = ?", (article_id,))
conn.commit()
return cursor.rowcount > 0
except sqlite3.Error as e:
print(f"Error deleting article: {e}")
return False

View File

@@ -20,6 +20,7 @@ class UserModel:
otp_code: Optional[str] = None
otp_expiration: Optional[str] = None
active: Optional[int] = None
can_create_articles: Optional[int] = 0
class Users:
def __init__(self, db_path="instance/app_database.db"):
@@ -46,10 +47,15 @@ class Users:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
otp_code TEXT,
otp_expiration TIMESTAMPTZ,
active INTEGER DEFAULT 1
active INTEGER DEFAULT 1,
can_create_articles INTEGER DEFAULT 0
);
"""
)
try:
cursor.execute("ALTER TABLE users ADD COLUMN can_create_articles INTEGER DEFAULT 0;")
except sqlite3.OperationalError:
pass
conn.commit()
def update_user_otp(self, user_id, otp_code, expiration):
@@ -117,7 +123,8 @@ class Users:
created_at=row[11],
otp_code=row[12],
otp_expiration=row[13],
active=row[14]
active=row[14],
can_create_articles=row[15] if len(row) > 15 else 0
)
def register_user(self, email, password, workspace_id):
@@ -141,10 +148,10 @@ class Users:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO users (workspace_id, first_name, last_name, email, password, address, profession, role, status, profile_pic)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (workspace_id, first_name, last_name, email, password, address, profession, role, status, profile_pic, can_create_articles)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(user.workspace_id, user.first_name, user.last_name, user.email, user.password, user.address, user.profession, user.role, user.status, user.profile_pic),
(user.workspace_id, user.first_name, user.last_name, user.email, user.password, user.address, user.profession, user.role, user.status, user.profile_pic, user.can_create_articles or 0),
)
conn.commit()
return cursor.lastrowid
@@ -174,7 +181,8 @@ class Users:
created_at=row[11],
otp_code=row[12],
otp_expiration=row[13],
active=row[14]
active=row[14],
can_create_articles=row[15] if len(row) > 15 else 0
)
def get_user_by_email(self, email: str) -> UserModel | None:
@@ -201,7 +209,8 @@ class Users:
created_at=row[11],
otp_code=row[12],
otp_expiration=row[13],
active=row[14]
active=row[14],
can_create_articles=row[15] if len(row) > 15 else 0
)
def get_users_by_workspace_id(self, workspace_id):
@@ -225,7 +234,8 @@ class Users:
created_at=row[11],
otp_code=row[12],
otp_expiration=row[13],
active=row[14]
active=row[14],
can_create_articles=row[15] if len(row) > 15 else 0
)
for row in rows
]
@@ -251,13 +261,14 @@ class Users:
created_at=row[11],
otp_code=row[12],
otp_expiration=row[13],
active=row[14]
active=row[14],
can_create_articles=row[15] if len(row) > 15 else 0
)
for row in rows
]
def update_user(self, user_id, first_name=None, last_name=None, email=None, password = None, address = None, profession = None, role = None, status = None, profile_pic=None, active=None):
if first_name is None and last_name is None and email is None and password is None and address is None and profession is None and role is None and status is None and profile_pic is None and active is None:
def update_user(self, user_id, first_name=None, last_name=None, email=None, password = None, address = None, profession = None, role = None, status = None, profile_pic=None, active=None, can_create_articles=None):
if first_name is None and last_name is None and email is None and password is None and address is None and profession is None and role is None and status is None and profile_pic is None and active is None and can_create_articles is None:
return False
fields = []
@@ -293,6 +304,9 @@ class Users:
if active is not None:
fields.append("active = ?")
params.append(active)
if can_create_articles is not None:
fields.append("can_create_articles = ?")
params.append(can_create_articles)
params.append(user_id)
query = f"UPDATE users SET {', '.join(fields)} WHERE id = ?"

111
server/routes/articles.py Normal file
View File

@@ -0,0 +1,111 @@
from flask import Blueprint, request, jsonify, render_template
from flask_jwt_extended import jwt_required, get_jwt_identity
from models.publications.articles import Articles, ArticleModel
from models.users import Users
from models.audit import Audit, AuditModel
articles_bp = Blueprint("articles", __name__)
audit = Audit()
@articles_bp.route("/editor", methods=["GET"])
@jwt_required()
def show_editor():
token = request.args.get("token", "")
article_id = request.args.get("article_id", "None")
return render_template("editor.html", token=token, article_id=article_id)
@articles_bp.route("/", methods=["GET"])
@jwt_required()
def get_all_articles():
articles_repo = Articles()
articles = articles_repo.get_all_articles()
return jsonify([vars(a) for a in articles]), 200
@articles_bp.route("/<int:article_id>", methods=["GET"])
@jwt_required()
def get_article(article_id):
articles_repo = Articles()
article = articles_repo.get_article(article_id)
if not article:
return jsonify({"error": "Articolul nu a fost gasit"}), 404
return jsonify(vars(article)), 200
@articles_bp.route("/add", methods=["POST"])
@jwt_required()
def add_article():
current_user_id = int(get_jwt_identity())
user_repo = Users()
user = user_repo.get_user(current_user_id)
if not user:
return jsonify({"error": "Utilizatorul nu a fost gasit"}), 404
# Verifică dacă utilizatorul are permisiunea de a crea articole
if not getattr(user, 'can_create_articles', 0) == 1:
audit.new_entry(AuditModel(user_id=current_user_id, action="Attempt to add article without permission", status="403 - Forbidden"))
return jsonify({"error": "Nu aveti permisiunea de a publica articole"}), 403
data = request.get_json()
title = data.get("title")
content = data.get("content")
if not title or not content:
return jsonify({"error": "Titlul si continutul sunt obligatorii"}), 400
articles_repo = Articles()
article_id = articles_repo.add_article(title, content, current_user_id)
if article_id:
audit.new_entry(AuditModel(user_id=current_user_id, action=f"Added article: {title}", status="201 - Created"))
return jsonify({"message": "Articol adaugat cu succes", "id": article_id}), 201
return jsonify({"error": "Eroare la adaugarea articolului"}), 500
@articles_bp.route("/update/<int:article_id>", methods=["PUT"])
@jwt_required()
def update_article(article_id):
current_user_id = int(get_jwt_identity())
articles_repo = Articles()
article = articles_repo.get_article(article_id)
if not article:
return jsonify({"error": "Articolul nu a fost gasit"}), 404
# Permite modificarea doar dacă utilizatorul curent este autorul articolului
if article.author_id != current_user_id:
audit.new_entry(AuditModel(user_id=current_user_id, action=f"Attempt to update article ID {article_id} owned by other user", status="403 - Forbidden"))
return jsonify({"error": "Puteti modifica doar articolele scrise de dumneavoastra"}), 403
data = request.get_json()
title = data.get("title")
content = data.get("content")
if not title or not content:
return jsonify({"error": "Titlul si continutul sunt obligatorii"}), 400
if articles_repo.update_article(article_id, title, content):
audit.new_entry(AuditModel(user_id=current_user_id, action=f"Updated article ID: {article_id}", status="200 - OK"))
return jsonify({"message": "Articol modificat cu succes"}), 200
return jsonify({"error": "Nu s-a putut modifica articolul"}), 500
@articles_bp.route("/delete/<int:article_id>", methods=["DELETE"])
@jwt_required()
def delete_article(article_id):
current_user_id = int(get_jwt_identity())
articles_repo = Articles()
article = articles_repo.get_article(article_id)
if not article:
return jsonify({"error": "Articolul nu a fost gasit"}), 404
# Permite ștergerea doar dacă utilizatorul curent este autorul articolului
if article.author_id != current_user_id:
audit.new_entry(AuditModel(user_id=current_user_id, action=f"Attempt to delete article ID {article_id} owned by other user", status="403 - Forbidden"))
return jsonify({"error": "Puteti sterge doar articolele scrise de dumneavoastra"}), 403
if articles_repo.delete_article(article_id):
audit.new_entry(AuditModel(user_id=current_user_id, action=f"Deleted article ID: {article_id}", status="200 - OK"))
return jsonify({"message": "Articol sters cu succes"}), 200
return jsonify({"error": "Nu s-a putut sterge articolul"}), 500

View File

@@ -94,9 +94,7 @@ def verify_code():
return jsonify({"error": "Missing email or verification code"}), 400
user = users.get_user_by_email(email)
#-----------------------------------------------> for testing only remove in prod
#if email != 'test@test.com':
#-----------------------------------------------> for testing only remove in prod
if not user or user.otp_code != code:
entry = AuditModel(user_id=user.id, action=f"Attempt to verify code: {email}", status='401 - Invalid code!')
audit.new_entry(entry)
@@ -235,7 +233,8 @@ def me():
'profile_pic': user.profile_pic,
'created_at': user.created_at,
'otp_code': user.otp_code,
'otp_expiration': user.otp_expiration
'otp_expiration': user.otp_expiration,
'can_create_articles': user.can_create_articles
}), 200

View File

@@ -207,6 +207,8 @@ def add_standard():
# Sursa este folderul unde Flet salvează implicit upload-urile
source_path = os.path.join(BASE_DIR, "client", "assets", "uploads", os.path.basename(path))
dest_path = os.path.join(DOCUMENTS_ROOT, path)
print(source_path)
print(dest_path)
if os.path.exists(source_path):
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
@@ -265,8 +267,8 @@ def delete_standard(id):
def add_custom():
user_id = get_jwt_identity()
data = request.get_json()
name = data.get("name")
path = data.get("path")
name = data.get("name", "").strip()
path = data.get("path", "").strip()
access = data.get("access")
if not name or not path:
@@ -274,17 +276,31 @@ def add_custom():
audit.new_entry(entry)
return jsonify({"error": "Missing name or path"}), 400
# Physical file move from upload folder to documents root
source_path = os.path.join(BASE_DIR, "client", "assets", "uploads", os.path.basename(path))
dest_path = os.path.join(DOCUMENTS_ROOT, path)
# Physical file move from upload folder to documents/Custom folder
filename = os.path.basename(path)
# The 'path' variable here is the filename from the client, not the full path.
# We construct the full path for the database entry later.
source_path = os.path.join(BASE_DIR, "client", "assets", "uploads", os.path.basename(path))
dest_dir = os.path.normpath(os.path.join(DOCUMENTS_ROOT, "Custom"))
dest_path = os.path.join(dest_dir, filename) # This is the actual destination path for shutil.move
print(source_path)
print(dest_path)
if os.path.exists(source_path):
# Ensure the destination directory exists (for custom docs we usually put them in root or a 'custom' folder)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
os.makedirs(dest_dir, exist_ok=True) # Ensure the destination directory exists
shutil.move(source_path, dest_path)
# Update the 'path' variable to be stored in the database to reflect its new location
db_path = f"Custom/{filename}"
else:
# Log an error and return a response if the source file doesn't exist
error_message = f"Source file not found for custom document: {source_path}"
print(f"ERROR: {error_message}") # For immediate debug visibility
entry = AuditModel(user_id=user_id, action=f"Failed to create custom document (file not found): {name}", status="404 - File not found")
audit.new_entry(entry)
return jsonify({"error": "Uploaded file not found on server."}), 404
docs_custom = DocumentsCustom()
custom = DocumentsCustomModel(user_id=user_id, name=name, path=path, access=access)
custom = DocumentsCustomModel(user_id=user_id, name=name, path=db_path, access=access) # Use db_path here
result = docs_custom.new_entry(custom)
if result:
@@ -292,6 +308,10 @@ def add_custom():
audit.new_entry(entry)
return jsonify({"message": "Custom document created successfully", "id": result}), 201
# If we reach here, it means the DB entry failed after the file was moved.
# This is a potential issue, as the file is moved but not recorded.
entry = AuditModel(user_id=user_id, action=f"Failed to create custom document (DB entry failed): {name}", status="500 - DB error")
audit.new_entry(entry)
return jsonify({"error": "Failed to create custom document"}), 500
@documents_bp.route("/customs", methods=["GET"])

View File

@@ -86,7 +86,8 @@ def update_user(user_id):
role=data.get("role"),
status=data.get("status"),
profile_pic=data.get("profile_pic"),
active=data.get("active")
active=data.get("active"),
can_create_articles=data.get("can_create_articles")
)
if success:

View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Editor Publicație</title>
<!-- EasyMDE CSS -->
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f3f4f6;
padding: 30px 10px;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.editor-card {
max-width: 900px;
margin: 0 auto;
background: white;
padding: 35px;
border-radius: 16px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
}
.editor-toolbar {
border: 1px solid #d1d5db !important;
border-radius: 8px 8px 0 0 !important;
background-color: #f9fafb;
}
.CodeMirror {
border: 1px solid #d1d5db !important;
border-radius: 0 0 8px 8px !important;
min-height: 400px;
font-size: 15px;
}
.form-control:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
}
</style>
</head>
<body>
<div class="editor-card">
<div class="d-flex align-items-center mb-4">
<svg class="text-primary me-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
<h2 class="m-0 fw-bold text-dark" id="editor-title-label">Adaugă Articol Nou</h2>
</div>
<div class="mb-4">
<label for="title" class="form-label fw-bold text-secondary">Titlu Articol</label>
<input type="text" class="form-control form-control-lg border-2" id="title" placeholder="Introduceți un titlu captivant...">
</div>
<div class="mb-4">
<label for="content" class="form-label fw-bold text-secondary">Conținut Publicație</label>
<textarea id="content"></textarea>
</div>
<div class="d-flex justify-content-end gap-3">
<button class="btn btn-light border btn-lg px-4" onclick="window.close()">Renunță</button>
<button class="btn btn-primary btn-lg px-5" id="save-btn" onclick="saveArticle()">Salvează</button>
</div>
<div class="alert mt-4 d-none" id="status-alert" role="alert"></div>
</div>
<!-- EasyMDE JS -->
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script>
// Inițializare editor Markdown
const easyMDE = new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
placeholder: "Scrieți conținutul publicației folosind formatarea din bara de sus...",
autosave: {
enabled: true,
uniqueId: "article_editor_autosave_temp",
delay: 2000,
},
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading-1", "heading-2", "heading-3", "|",
"quote", "unordered-list", "ordered-list", "|",
"link", "image", "table", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
]
});
// Preluare parametri din șablonul Jinja
const articleId = "{{ article_id }}";
const token = "{{ token }}";
// Dacă avem ID de articol, înseamnă că suntem în modul editare
if (articleId && articleId !== "None") {
document.getElementById('editor-title-label').innerText = "Editează Articolul";
// Încarcă datele articolului de pe server
fetch(`/articles/${articleId}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => {
if (!res.ok) throw new Error("Nu s-au putut încărca datele articolului.");
return res.json();
})
.then(data => {
if (data.title) document.getElementById('title').value = data.title;
if (data.content) easyMDE.value(data.content);
})
.catch(err => {
showStatus(err.message, "danger");
});
}
function showStatus(message, type) {
const alertDiv = document.getElementById('status-alert');
alertDiv.className = `alert alert-${type} mt-4`;
alertDiv.innerText = message;
alertDiv.classList.remove('d-none');
}
function saveArticle() {
const title = document.getElementById('title').value.trim();
const content = easyMDE.value().trim();
if (!title || !content) {
showStatus("Titlul și conținutul sunt obligatorii!", "danger");
return;
}
const payload = { title, content };
const url = (articleId && articleId !== "None") ? `/articles/update/${articleId}` : '/articles/add';
const method = (articleId && articleId !== "None") ? 'PUT' : 'POST';
const saveBtn = document.getElementById('save-btn');
saveBtn.disabled = true;
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
})
.then(async res => {
const data = await res.json();
if (res.status === 200 || res.status === 201) {
showStatus("Publicația a fost salvată cu succes! Această fereastră se va închide...", "success");
// Șterge salvările automate temporare
easyMDE.clearAutosavedValue();
setTimeout(() => {
window.close();
}, 1500);
} else {
throw new Error(data.error || "A apărut o eroare la salvare.");
}
})
.catch(err => {
showStatus(err.message, "danger");
saveBtn.disabled = false;
});
}
</script>
</body>
</html>