add article and pubications
This commit is contained in:
@@ -15,7 +15,7 @@ class DocumentsStatus:
|
||||
mapping = {
|
||||
DocumentsStatus.NEW: 'Nou',
|
||||
DocumentsStatus.ANALISE: 'Analiza',
|
||||
DocumentsStatus.IN_PROGRESS: 'In progres',
|
||||
DocumentsStatus.IN_PROGRESS: 'In lucru',
|
||||
DocumentsStatus.WAITING_FOR_PAYMENT: 'Asteptam plata',
|
||||
DocumentsStatus.COMPLETED: 'Complet',
|
||||
DocumentsStatus.CANCELED: 'Anulat'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import flet as ft
|
||||
from pages.documents.ba import Documents
|
||||
from pages.publications.articles import Articles as ExpertArticlesPage
|
||||
from pages.settings.settings import Settings
|
||||
|
||||
class NavigationBA:
|
||||
@@ -13,8 +14,8 @@ class NavigationBA:
|
||||
label="Documente Custom",
|
||||
)
|
||||
self.articole_si_publicatii = ft.NavigationRailDestination(
|
||||
icon=ft.Icons.ARTICLE_OUTLINED,
|
||||
selected_icon=ft.Icons.ARTICLE,
|
||||
icon=ft.Icons.MENU_BOOK_OUTLINED, # Icon mai potrivit pentru articole/blog
|
||||
selected_icon=ft.Icons.MENU_BOOK,
|
||||
label="Articole si Publicatii",
|
||||
)
|
||||
self.comunicare = ft.NavigationRailDestination(
|
||||
@@ -61,12 +62,7 @@ class NavigationBA:
|
||||
def build(self):
|
||||
return [
|
||||
self.documente_juridice,
|
||||
# self.articole_si_publicatii,
|
||||
# self.comunicare,
|
||||
# self.consultanta,
|
||||
# self.convocator,
|
||||
# self.licitatii_si_lucrari,
|
||||
# self.profil,
|
||||
self.articole_si_publicatii,
|
||||
self.abonamente,
|
||||
self.setari,
|
||||
self.logout
|
||||
@@ -76,21 +72,31 @@ class NavigationBA:
|
||||
self.docs = Documents(self.page, self.home)
|
||||
return self.docs.build()
|
||||
|
||||
def build_articole_si_publicatii_expert(self):
|
||||
self.articles_page = ExpertArticlesPage(self.page, self.home)
|
||||
return self.articles_page.build()
|
||||
|
||||
def build_subscriptions(self):
|
||||
from pages.subscriptions.subscriptions import SubscriptionsPage
|
||||
self.subs_page = SubscriptionsPage(self.page)
|
||||
return self.subs_page.build()
|
||||
|
||||
async def on_nav_change(self, e):
|
||||
print( "Selected destination:", e.control.selected_index)
|
||||
if e.control.selected_index == 0:
|
||||
self.home.placeholder.content = self.build_documente_juridice()
|
||||
self.page.update()
|
||||
|
||||
if e.control.selected_index == 1:
|
||||
pass
|
||||
|
||||
if e.control.selected_index == 2:
|
||||
elif e.control.selected_index == 1: # Index for Articole si Publicatii
|
||||
self.home.placeholder.content = self.build_articole_si_publicatii_expert()
|
||||
self.page.update()
|
||||
elif e.control.selected_index == 2: # Abonamente
|
||||
self.home.placeholder.content = self.build_subscriptions()
|
||||
self.page.update()
|
||||
elif e.control.selected_index == 3: # Setari
|
||||
self.settings = Settings(self.page, self)
|
||||
self.home.placeholder.content = self.settings.build()
|
||||
self.page.update() # Actualizează întreaga pagină inclusiv overlay-ul
|
||||
|
||||
if e.control.selected_index == 3:
|
||||
elif e.control.selected_index == 4: # Logout
|
||||
await ft.SharedPreferences().clear()
|
||||
self.page.session.store.clear()
|
||||
self.page.go('/auth')
|
||||
@@ -1,5 +1,6 @@
|
||||
import flet as ft
|
||||
from pages.documents.home import DocumentsHome
|
||||
from pages.publications.articles import Articles as ClientArticlesPage
|
||||
|
||||
class NavigationUser:
|
||||
def __init__(self, page: ft.Page, home):
|
||||
@@ -12,8 +13,8 @@ class NavigationUser:
|
||||
label="Documente Juridice",
|
||||
)
|
||||
self.articole_si_publicatii = ft.NavigationRailDestination(
|
||||
icon=ft.Icons.ARTICLE_OUTLINED,
|
||||
selected_icon=ft.Icons.ARTICLE,
|
||||
icon=ft.Icons.MENU_BOOK_OUTLINED, # Icon mai potrivit pentru articole/blog
|
||||
selected_icon=ft.Icons.MENU_BOOK,
|
||||
label="Articole si Publicatii",
|
||||
)
|
||||
self.comunicare = ft.NavigationRailDestination(
|
||||
@@ -75,19 +76,23 @@ class NavigationUser:
|
||||
self.docs = DocumentsHome(self.page, self.home)
|
||||
return self.docs.build()
|
||||
|
||||
def build_articole_si_publicatii_client(self):
|
||||
self.articles_page = ClientArticlesPage(self.page, self.home)
|
||||
return self.articles_page.build()
|
||||
|
||||
async def on_nav_change(self, e):
|
||||
print( "Selected destination:", e.control.selected_index)
|
||||
if e.control.selected_index == 0:
|
||||
self.home.placeholder.content = self.build_documente_juridice()
|
||||
self.page.update()
|
||||
|
||||
if e.control.selected_index == 1:
|
||||
pass
|
||||
elif e.control.selected_index == 1: # Index for Articole si Publicatii
|
||||
self.home.placeholder.content = self.build_articole_si_publicatii_client()
|
||||
self.page.update()
|
||||
|
||||
if e.control.selected_index == 2:
|
||||
pass
|
||||
|
||||
if e.control.selected_index == 3:
|
||||
if e.control.selected_index == 9:
|
||||
await ft.SharedPreferences().clear()
|
||||
self.page.session.store.clear()
|
||||
self.page.go('/auth')
|
||||
@@ -47,6 +47,7 @@ class ForgotPassword:
|
||||
self.go_to_login
|
||||
]
|
||||
)
|
||||
self.verifica_btn = ft.Button("Verifica", width=150, on_click=self.verfy_code)
|
||||
|
||||
def _generate_numeric_code(self) -> str:
|
||||
digits = string.digits
|
||||
@@ -58,7 +59,7 @@ class ForgotPassword:
|
||||
self.main_column.controls.append(self.title)
|
||||
self.main_column.controls.append(self.inserted_code)
|
||||
self.main_column.controls.append(self.error_message)
|
||||
self.main_column.controls.append("Verifica", width=150, on_click=self.verfy_code)
|
||||
self.main_column.controls.append(self.verifica_btn)
|
||||
self.main_column.update()
|
||||
#print(self.otp_code)
|
||||
send_gmail(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import flet as ft
|
||||
import requests
|
||||
|
||||
|
||||
from navigation.ba import NavigationBA
|
||||
from navigation.user import NavigationUser
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
||||
from helpers.document_status import DocumentsStatus
|
||||
from helpers.emails import send_gmail
|
||||
from dataclasses import dataclass, field
|
||||
import asyncio
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
@@ -11,6 +12,7 @@ class State:
|
||||
file_picker: ft.FilePicker | None = None
|
||||
picked_files: list[ft.FilePickerFile] = field(default_factory=list)
|
||||
|
||||
|
||||
state = State()
|
||||
|
||||
class Documents:
|
||||
@@ -21,11 +23,11 @@ class Documents:
|
||||
self.token = self.page.session.store.get('token')
|
||||
self.user = self.page.session.store.get('user')
|
||||
self.user_id = self.user['id'] if self.user else None
|
||||
self.documenet_title = None
|
||||
|
||||
self.all_requests = []
|
||||
self.current_selected_request = None
|
||||
|
||||
|
||||
# Elemente interfață: Căutare și Listă
|
||||
self.search_bar = ft.TextField(
|
||||
label="Căutare solicitări (Text solicitare sau comentariu)",
|
||||
@@ -87,6 +89,11 @@ class Documents:
|
||||
|
||||
self.doc_id_info = ft.Text("Document Final: Niciunul", color=ft.Colors.GREY_700)
|
||||
|
||||
self.download_button = ft.IconButton(
|
||||
icon=ft.Icons.DOWNLOAD,
|
||||
on_click=self._on_download_button_click,
|
||||
)
|
||||
|
||||
self.details_panel = ft.Column(
|
||||
[
|
||||
self.req_id_text,
|
||||
@@ -108,15 +115,23 @@ class Documents:
|
||||
]),
|
||||
ft.Divider(),
|
||||
ft.Text("Finalizare și Încărcare Document:", weight=ft.FontWeight.BOLD),
|
||||
ft.Row([
|
||||
ft.FilledButton(
|
||||
"Încarcă Document Final",
|
||||
icon=ft.Icons.UPLOAD_FILE,
|
||||
on_click=self._handle_file_upload,
|
||||
bgcolor=ft.Colors.GREEN_700
|
||||
),
|
||||
self.doc_id_info
|
||||
])
|
||||
|
||||
ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.FilledButton(
|
||||
"Încarcă Document Final",
|
||||
icon=ft.Icons.UPLOAD_FILE,
|
||||
on_click=self._handle_file_upload,
|
||||
bgcolor=ft.Colors.GREEN_700
|
||||
),
|
||||
self.doc_id_info,
|
||||
self.download_button
|
||||
]),
|
||||
ft.FilledButton("Salveaza si trimite", on_click=self._on_file_saved)
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN
|
||||
)
|
||||
],
|
||||
visible=False,
|
||||
expand=True,
|
||||
@@ -301,8 +316,8 @@ class Documents:
|
||||
print(f"Update failed: {e}")
|
||||
return False
|
||||
|
||||
async def _handle_file_upload(self, e):
|
||||
if not self.current_selected_request: return
|
||||
async def _handle_file_upload(self, e: ft.Event[ft.Button]):
|
||||
print('File uploaded')
|
||||
try:
|
||||
state.file_picker = ft.FilePicker()
|
||||
files = await state.file_picker.pick_files(allow_multiple=False)
|
||||
@@ -311,35 +326,68 @@ class Documents:
|
||||
state.picked_files = files
|
||||
uploaded_file_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{state.picked_files[0].name}"
|
||||
await state.file_picker.upload(
|
||||
files=[
|
||||
ft.FilePickerUploadFile(
|
||||
name=file.name,
|
||||
upload_url=self.page.get_upload_url(uploaded_file_name, 60),
|
||||
files=[
|
||||
ft.FilePickerUploadFile(
|
||||
name=file.name,
|
||||
upload_url=self.page.get_upload_url(uploaded_file_name, 60),
|
||||
|
||||
)
|
||||
)
|
||||
|
||||
for file in state.picked_files
|
||||
]
|
||||
for file in state.picked_files
|
||||
]
|
||||
)
|
||||
self.documenet_title = uploaded_file_name
|
||||
self.doc_id_info.value = f"ID Document asociat: {self.documenet_title}"
|
||||
return f'{uploaded_file_name}'
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
# 2. Înregistrare în tabela documents_custom
|
||||
def _on_file_saved(self, e):
|
||||
|
||||
reg_resp = requests.post(
|
||||
f"{self.base_url}/documents/customs/add",
|
||||
json={"name": f"Document Final Solicitare #{self.current_selected_request['id']}", "path": uploaded_file_name},
|
||||
reg_resp = requests.post(
|
||||
f"{self.base_url}/documents/customs/add",
|
||||
json={"name": f"Document Final Solicitare #{self.current_selected_request['id']}", "path": self.documenet_title},
|
||||
headers={'Authorization': f'Bearer {self.token}'}
|
||||
)
|
||||
|
||||
if reg_resp.status_code == 201:
|
||||
doc_id = reg_resp.json().get('id')
|
||||
# 3. Legare document de solicitare și marcare ca finalizat
|
||||
self._update_request_api({
|
||||
"document_id": doc_id,
|
||||
"status": DocumentsStatus.COMPLETED,
|
||||
"expert_id": self.user_id,
|
||||
})
|
||||
|
||||
async def _on_download_button_click(self, e):
|
||||
"""Handles the download of the final document."""
|
||||
if not self.current_selected_request or not self.current_selected_request.get('document_id'):
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text("Nu există un document final asociat acestei solicitări."), open=True))
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
document_id = self.current_selected_request['document_id']
|
||||
try:
|
||||
# Fetch document details to get the path
|
||||
response = requests.get(
|
||||
f"{self.base_url}/documents/customs/{document_id}",
|
||||
headers={'Authorization': f'Bearer {self.token}'}
|
||||
)
|
||||
|
||||
if reg_resp.status_code == 201:
|
||||
doc_id = reg_resp.json().get('id')
|
||||
# 3. Legare document de solicitare și marcare ca finalizat
|
||||
self._update_request_api({
|
||||
"document_id": doc_id,
|
||||
"status": DocumentsStatus.COMPLETED,
|
||||
"expert_id": self.user_id,
|
||||
})
|
||||
if response.status_code == 200:
|
||||
document_data = response.json()
|
||||
document_path = document_data.get('path')
|
||||
if document_path:
|
||||
download_url = f"{self.base_url}/documents/download?path={document_path}&token={self.token}"
|
||||
await self.page.launch_url(download_url)
|
||||
else:
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text("Calea documentului nu a putut fi găsită."), open=True))
|
||||
else:
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text(f"Eroare la preluarea detaliilor documentului: {response.status_code}"), open=True))
|
||||
except requests.exceptions.RequestException as err:
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text(f"Eroare de rețea la descărcarea documentului: {err}"), open=True))
|
||||
except Exception as ex:
|
||||
print(f"Error during file upload or registration: {ex}")
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text(f"A apărut o eroare neașteptată: {ex}"), open=True))
|
||||
self.page.update()
|
||||
|
||||
def build(self):
|
||||
return ft.Container(
|
||||
|
||||
@@ -73,6 +73,13 @@ class Documents:
|
||||
disabled=True, # Will be enabled based on status
|
||||
visible=False # Initially hidden
|
||||
)
|
||||
self.download_button = ft.FilledButton(
|
||||
"Descarcă Document Final",
|
||||
icon=ft.Icons.DOWNLOAD,
|
||||
on_click=self._on_download_button_click,
|
||||
disabled=True, # Will be enabled based on status and document_id
|
||||
visible=False # Initially hidden
|
||||
)
|
||||
self.comment_text_field = ft.TextField(
|
||||
label="Adauga un comentariu",
|
||||
multiline=True,
|
||||
@@ -96,11 +103,13 @@ class Documents:
|
||||
ft.Text("Pret:", weight=ft.FontWeight.BOLD),
|
||||
self.request_price_text,
|
||||
self.pay_button,
|
||||
self.download_button,
|
||||
ft.Divider(),
|
||||
ft.Text("Adauga Comentariu:", weight=ft.FontWeight.BOLD),
|
||||
ft.Row([self.comment_text_field, self.add_comment_button]),
|
||||
],
|
||||
expand=True,
|
||||
scroll=ft.ScrollMode.ADAPTIVE,
|
||||
visible=False # Initially hidden
|
||||
)
|
||||
|
||||
@@ -186,6 +195,14 @@ class Documents:
|
||||
self.pay_button.visible = False
|
||||
self.pay_button.disabled = True
|
||||
|
||||
# Handle Download button visibility and state
|
||||
if request_data.get('status') == DocumentsStatus.COMPLETED and request_data.get('document_id'):
|
||||
self.download_button.visible = True
|
||||
self.download_button.disabled = False
|
||||
else:
|
||||
self.download_button.visible = False
|
||||
self.download_button.disabled = True
|
||||
|
||||
# Enable comment section
|
||||
self.add_comment_button.disabled = False
|
||||
self.comment_text_field.disabled = False
|
||||
@@ -308,6 +325,36 @@ class Documents:
|
||||
))
|
||||
self.page.update()
|
||||
|
||||
async def _on_download_button_click(self, e):
|
||||
"""Handles the download of the final document."""
|
||||
if not self.current_selected_request or not self.current_selected_request.get('document_id'):
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text("Nu există un document final asociat acestei solicitări."), open=True))
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
document_id = self.current_selected_request['document_id']
|
||||
try:
|
||||
# Fetch document details to get the path
|
||||
response = requests.get(
|
||||
f"{self.base_url}/documents/customs/{document_id}",
|
||||
headers={'Authorization': f'Bearer {self.token}'}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
document_data = response.json()
|
||||
document_path = document_data.get('path')
|
||||
if document_path:
|
||||
download_url = f"{self.base_url}/documents/download?path={document_path}&token={self.token}"
|
||||
await self.page.launch_url(download_url)
|
||||
else:
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text("Calea documentului nu a putut fi găsită."), open=True))
|
||||
else:
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text(f"Eroare la preluarea detaliilor documentului: {response.status_code}"), open=True))
|
||||
except requests.exceptions.RequestException as err:
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text(f"Eroare de rețea la descărcarea documentului: {err}"), open=True))
|
||||
except Exception as ex:
|
||||
self.page.show_dialog(ft.SnackBar(ft.Text(f"A apărut o eroare neașteptată: {ex}"), open=True))
|
||||
self.page.update()
|
||||
|
||||
def _close_dialog(self, e):
|
||||
self.page.pop_dialog()
|
||||
self.page.update()
|
||||
|
||||
BIN
client/pages/publications/__pycache__/articles.cpython-313.pyc
Normal file
BIN
client/pages/publications/__pycache__/articles.cpython-313.pyc
Normal file
Binary file not shown.
334
client/pages/publications/articles.py
Normal file
334
client/pages/publications/articles.py
Normal file
@@ -0,0 +1,334 @@
|
||||
import flet as ft
|
||||
import requests
|
||||
from helpers.roles import Roles
|
||||
|
||||
class Articles:
|
||||
def __init__(self, page: ft.Page, home):
|
||||
self.page = page
|
||||
self.home = home
|
||||
self.base_url = self.page.session.store.get('api_base_url')
|
||||
self.token = self.page.session.store.get('token')
|
||||
self.user = self.page.session.store.get('user') or {}
|
||||
|
||||
self.all_articles = []
|
||||
self.selected_article_id = None
|
||||
|
||||
# UI Elements for Search
|
||||
self.search_bar = ft.TextField(
|
||||
label="Caută articole după titlu...",
|
||||
prefix_icon=ft.Icons.SEARCH,
|
||||
on_change=self.on_search_change,
|
||||
expand=True,
|
||||
hint_text="Introdu titlul sau fragmente din titlu..."
|
||||
)
|
||||
|
||||
# UI list container
|
||||
self.articles_container = ft.Column(
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
spacing=15,
|
||||
expand=True
|
||||
)
|
||||
|
||||
# Delete confirmation dialog
|
||||
self.article_id_to_delete = None
|
||||
self.delete_confirm_dialog = ft.AlertDialog(
|
||||
title=ft.Text("Confirmare Ștergere"),
|
||||
content=ft.Text("Sigur doriți să ștergeți acest articol? Această acțiune este ireversibilă."),
|
||||
actions=[
|
||||
ft.FilledButton("Șterge", on_click=self.confirm_delete_article, bgcolor=ft.Colors.RED),
|
||||
ft.FilledButton("Anulează", on_click=self.close_dialog, bgcolor=ft.Colors.GREY)
|
||||
],
|
||||
actions_alignment=ft.MainAxisAlignment.END
|
||||
)
|
||||
|
||||
# Read Article Modal Dialog
|
||||
self.read_title = ft.Text(size=22, weight=ft.FontWeight.BOLD)
|
||||
self.read_meta = ft.Text(size=12, italic=True, color=ft.Colors.GREY_600)
|
||||
self.read_content = ft.Markdown(selectable=True, expand=True)
|
||||
|
||||
self.read_article_dialog = ft.AlertDialog(
|
||||
content=ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
self.read_title,
|
||||
self.read_meta,
|
||||
ft.Divider(height=10),
|
||||
ft.Column(
|
||||
[self.read_content],
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
expand=True
|
||||
)
|
||||
],
|
||||
spacing=10,
|
||||
),
|
||||
width=650,
|
||||
height=500,
|
||||
),
|
||||
actions=[
|
||||
ft.TextButton("Închide", on_click=self.close_dialog)
|
||||
]
|
||||
)
|
||||
|
||||
def load_articles(self):
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.base_url}/articles/",
|
||||
headers={"Authorization": f"Bearer {self.token}"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
self.all_articles = response.json()
|
||||
else:
|
||||
self.all_articles = []
|
||||
except Exception as e:
|
||||
print(f"Error fetching articles: {e}")
|
||||
self.all_articles = []
|
||||
|
||||
def populate_articles_list(self, query=""):
|
||||
self.articles_container.controls.clear()
|
||||
query = query.strip().lower()
|
||||
|
||||
filtered = [
|
||||
art for art in self.all_articles
|
||||
if query in art.get('title', '').lower()
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
self.articles_container.controls.append(
|
||||
ft.Container(
|
||||
content=ft.Text("Nu s-au găsit articole publicate.", size=16, color=ft.Colors.GREY_600),
|
||||
alignment=ft.Alignment.CENTER,
|
||||
padding=40
|
||||
)
|
||||
)
|
||||
else:
|
||||
can_publish = self.user.get('can_create_articles') == 1
|
||||
user_id = self.user.get('id')
|
||||
|
||||
for art in filtered:
|
||||
# Curățăm formatarea markdown pentru previzualizare
|
||||
preview = self.strip_markdown(art.get('content', ''))
|
||||
if len(preview) > 180:
|
||||
preview = preview[:180] + "..."
|
||||
|
||||
# Check if logged-in user is the author of this post
|
||||
is_author = art.get('author_id') == user_id
|
||||
|
||||
# Formatează data
|
||||
raw_date = art.get('created_at', '')
|
||||
formatted_date = raw_date.replace("T", " ").split(".")[0] if raw_date else "Dată necunoscută"
|
||||
|
||||
# Row of actions
|
||||
actions_row = ft.Row(
|
||||
[
|
||||
ft.TextButton(
|
||||
"Citește Articolul",
|
||||
icon=ft.Icons.ARROW_FORWARD,
|
||||
on_click=lambda e, a=art: self.open_read_dialog(a)
|
||||
)
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN
|
||||
)
|
||||
|
||||
# Daca utilizatorul este autorul, adaugam butoane de Edit/Delete
|
||||
if can_publish and is_author:
|
||||
actions_row.controls.append(
|
||||
ft.Row(
|
||||
[
|
||||
ft.IconButton(
|
||||
icon=ft.Icons.EDIT_OUTLINED,
|
||||
tooltip="Editează articolul",
|
||||
icon_color=ft.Colors.BLUE_700,
|
||||
on_click=lambda e, a=art: self.page.run_task(self.open_edit_dialog, a)
|
||||
),
|
||||
ft.IconButton(
|
||||
icon=ft.Icons.DELETE_OUTLINE,
|
||||
tooltip="Șterge articolul",
|
||||
icon_color=ft.Colors.RED_600,
|
||||
on_click=lambda e, a_id=art['id']: self.open_delete_confirm(a_id)
|
||||
)
|
||||
],
|
||||
spacing=5
|
||||
)
|
||||
)
|
||||
|
||||
self.articles_container.controls.append(
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text(art.get('title', 'Fără Titlu'), weight=ft.FontWeight.BOLD, size=18, color=ft.Colors.BLUE_GREY_900),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Icon(ft.Icons.PERSON_OUTLINE, size=14, color=ft.Colors.GREY_500),
|
||||
ft.Text(f"Autor: {art.get('author_name', 'Necunoscut')}", size=12, color=ft.Colors.GREY_600),
|
||||
ft.VerticalDivider(width=1, color=ft.Colors.GREY_300),
|
||||
ft.Icon(ft.Icons.ACCESS_TIME, size=14, color=ft.Colors.GREY_500),
|
||||
ft.Text(f"Publicat: {formatted_date}", size=12, color=ft.Colors.GREY_600),
|
||||
],
|
||||
spacing=10
|
||||
),
|
||||
ft.Divider(height=5, color=ft.Colors.TRANSPARENT),
|
||||
ft.Text(preview, size=14, color=ft.Colors.GREY_700),
|
||||
ft.Divider(height=10),
|
||||
actions_row
|
||||
],
|
||||
spacing=10
|
||||
),
|
||||
bgcolor=ft.Colors.WHITE,
|
||||
padding=20,
|
||||
border_radius=12,
|
||||
border=ft.Border.all(1, ft.Colors.GREY_200),
|
||||
shadow=ft.BoxShadow(
|
||||
blur_radius=8,
|
||||
color=ft.Colors.with_opacity(0.04, ft.Colors.BLACK),
|
||||
offset=ft.Offset(0, 3)
|
||||
)
|
||||
)
|
||||
)
|
||||
#self.articles_container.update()
|
||||
|
||||
def on_search_change(self, e):
|
||||
self.populate_articles_list(self.search_bar.value)
|
||||
|
||||
def strip_markdown(self, text):
|
||||
if not text:
|
||||
return ""
|
||||
import re
|
||||
# Elimină antetele (ex: # Titlu 1)
|
||||
text = re.sub(r'(?m)^#{1,6}\s+', '', text)
|
||||
# Elimină marcatorii de listă (ex: - element)
|
||||
text = re.sub(r'(?m)^[\-\*\+]\s+', '', text)
|
||||
# Elimină caracterele pentru bold, italic și inline-code
|
||||
text = re.sub(r'\*\*|__|\*|_|`', '', text)
|
||||
# Înlocuiește multiplele spații sau linii noi cu un singur spațiu
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
return text.strip()
|
||||
|
||||
# Creation/Editing Dialog (deschise în tab separat)
|
||||
async def open_add_dialog(self, e):
|
||||
editor_url = f"{self.base_url}/articles/editor?token={self.token}"
|
||||
await self.page.launch_url(editor_url)
|
||||
|
||||
async def open_edit_dialog(self, article):
|
||||
print('edit')
|
||||
editor_url = f"{self.base_url}/articles/editor?token={self.token}&article_id={article['id']}"
|
||||
await self.page.launch_url(editor_url)
|
||||
|
||||
# Delete Dialog
|
||||
def open_delete_confirm(self, article_id):
|
||||
self.article_id_to_delete = article_id
|
||||
self.page.show_dialog(self.delete_confirm_dialog)
|
||||
self.page.update()
|
||||
|
||||
def confirm_delete_article(self, e):
|
||||
if not self.article_id_to_delete:
|
||||
return
|
||||
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{self.base_url}/articles/delete/{self.article_id_to_delete}",
|
||||
headers={"Authorization": f"Bearer {self.token}"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
self.page.pop_dialog()
|
||||
self.load_articles()
|
||||
self.populate_articles_list(self.search_bar.value)
|
||||
self.show_snack_bar("Articolul a fost șters cu succes.", ft.Colors.GREEN)
|
||||
else:
|
||||
self.page.pop_dialog()
|
||||
err = response.json().get('error', 'Eroare la ștergerea articolului.')
|
||||
self.show_snack_bar(err, ft.Colors.RED)
|
||||
except Exception as ex:
|
||||
print(f"Error deleting article: {ex}")
|
||||
self.page.pop_dialog()
|
||||
self.show_snack_bar("Eroare de conexiune la server.", ft.Colors.RED)
|
||||
|
||||
# Read modal dialog
|
||||
def open_read_dialog(self, article):
|
||||
self.read_title.value = article.get('title', 'Fără Titlu')
|
||||
raw_date = article.get('created_at', '')
|
||||
formatted_date = raw_date.replace("T", " ").split(".")[0] if raw_date else "Dată necunoscută"
|
||||
self.read_meta.value = f"Scris de {article.get('author_name', 'Necunoscut')} la data de {formatted_date}"
|
||||
self.read_content.value = article.get('content', '')
|
||||
|
||||
self.page.show_dialog(self.read_article_dialog)
|
||||
self.page.update()
|
||||
|
||||
def close_dialog(self, e):
|
||||
self.page.pop_dialog()
|
||||
|
||||
def show_snack_bar(self, message, color):
|
||||
self.page.snack_bar = ft.SnackBar(
|
||||
content=ft.Text(message, color=ft.Colors.WHITE),
|
||||
bgcolor=color,
|
||||
duration=3000
|
||||
)
|
||||
self.page.snack_bar.open = True
|
||||
self.page.update()
|
||||
|
||||
def on_refresh_click(self, e):
|
||||
self.load_articles()
|
||||
self.populate_articles_list(self.search_bar.value)
|
||||
self.show_snack_bar("Lista de articole a fost actualizată.", ft.Colors.GREEN)
|
||||
|
||||
def build(self):
|
||||
self.load_articles()
|
||||
self.populate_articles_list()
|
||||
|
||||
# Check if the user is an expert with posting privileges
|
||||
can_publish = self.user.get('can_create_articles') == 1
|
||||
|
||||
header_row = ft.Row(
|
||||
[
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text("Articole și Publicații", size=24, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_900),
|
||||
ft.Text("Răsfoiți noutățile, ghidurile și publicațiile juridice recente.", size=14, color=ft.Colors.GREY_600)
|
||||
],
|
||||
spacing=2
|
||||
)
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN
|
||||
)
|
||||
|
||||
# Dacă utilizatorul are bifa de publicare, adăugăm butonul de Adăugare
|
||||
if can_publish:
|
||||
header_row.controls.append(
|
||||
ft.FilledButton(
|
||||
"Adaugă Articol",
|
||||
icon=ft.Icons.ADD,
|
||||
#on_click=lambda e: self.page.run_task(self.open_add_dialog(e)),
|
||||
on_click=self.open_add_dialog,
|
||||
style=ft.ButtonStyle(
|
||||
shape=ft.RoundedRectangleBorder(radius=8)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
refresh_btn = ft.IconButton(
|
||||
icon=ft.Icons.REFRESH,
|
||||
tooltip="Reîncarcă articolele",
|
||||
icon_color=ft.Colors.BLUE_700,
|
||||
on_click=self.on_refresh_click
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
header_row,
|
||||
ft.Divider(height=10, color=ft.Colors.TRANSPARENT),
|
||||
ft.Row(
|
||||
[
|
||||
self.search_bar,
|
||||
refresh_btn
|
||||
],
|
||||
spacing=10
|
||||
),
|
||||
ft.Divider(height=10, color=ft.Colors.GREY_100),
|
||||
self.articles_container
|
||||
],
|
||||
expand=True
|
||||
),
|
||||
expand=True,
|
||||
padding=20,
|
||||
bgcolor=ft.Colors.GREY_50
|
||||
)
|
||||
188
client/pages/settings/articles.py
Normal file
188
client/pages/settings/articles.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import flet as ft
|
||||
import requests
|
||||
from helpers.roles import Roles
|
||||
|
||||
class ArticlesSettings:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self.base_url = self.page.session.store.get('api_base_url')
|
||||
self.token = self.page.session.store.get('token')
|
||||
|
||||
self.search_bar = ft.TextField(
|
||||
label="Caută expert...",
|
||||
prefix_icon=ft.Icons.SEARCH,
|
||||
on_change=self.on_search_change,
|
||||
expand=True
|
||||
)
|
||||
|
||||
self.experts_list = ft.ListView(
|
||||
spacing=10,
|
||||
expand=True,
|
||||
padding=10
|
||||
)
|
||||
|
||||
self.all_experts = []
|
||||
|
||||
def get_experts(self):
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.base_url}/users/",
|
||||
headers={'Authorization': f'Bearer {self.token}'}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
users = response.json()
|
||||
# Filtrăm doar utilizatorii cu rol de expert sau ba
|
||||
self.all_experts = [u for u in users if u.get('role') in [Roles.EXPERT, Roles.BA]]
|
||||
return self.all_experts
|
||||
except Exception as e:
|
||||
print(f"Error fetching experts: {e}")
|
||||
return []
|
||||
|
||||
def load_experts_list(self, query=""):
|
||||
self.experts_list.controls.clear()
|
||||
query = query.strip().lower()
|
||||
|
||||
filtered = [
|
||||
exp for exp in self.all_experts
|
||||
if query in f"{exp.get('first_name', '')} {exp.get('last_name', '')} {exp.get('email', '')}".lower()
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
self.experts_list.controls.append(
|
||||
ft.Container(
|
||||
content=ft.Text("Nu s-au găsit experți.", size=16, color=ft.Colors.GREY_600),
|
||||
alignment=ft.Alignment.CENTER,
|
||||
padding=20
|
||||
)
|
||||
)
|
||||
else:
|
||||
for exp in filtered:
|
||||
expert_id = exp['id']
|
||||
name = f"{exp.get('first_name') or ''} {exp.get('last_name') or ''}".strip() or "Nume nespecificat"
|
||||
email = exp.get('email', 'Email nespecificat')
|
||||
role_label = exp.get('role', '').upper()
|
||||
can_create = exp.get('can_create_articles') == 1
|
||||
|
||||
self.experts_list.controls.append(
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Row(
|
||||
[
|
||||
ft.CircleAvatar(
|
||||
content=ft.Icon(ft.Icons.PERSON, color=ft.Colors.BLUE_800),
|
||||
bgcolor=ft.Colors.BLUE_50,
|
||||
radius=20
|
||||
),
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text(name, weight=ft.FontWeight.BOLD, size=15),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Text(email, size=13, color=ft.Colors.GREY_600),
|
||||
ft.Container(
|
||||
content=ft.Text(role_label, size=10, color=ft.Colors.BLUE_800, weight=ft.FontWeight.BOLD),
|
||||
bgcolor=ft.Colors.BLUE_50,
|
||||
padding=ft.Padding(5, 2, 5, 2),
|
||||
border_radius=4
|
||||
)
|
||||
],
|
||||
spacing=10
|
||||
)
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
spacing=2
|
||||
)
|
||||
],
|
||||
spacing=15
|
||||
),
|
||||
ft.Switch(
|
||||
value=can_create,
|
||||
on_change=lambda e, uid=expert_id, uemail=email: self.toggle_permission(e, uid, uemail)
|
||||
)
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN
|
||||
),
|
||||
bgcolor=ft.Colors.WHITE,
|
||||
padding=15,
|
||||
border_radius=10,
|
||||
border=ft.Border.all(1, ft.Colors.GREY_200),
|
||||
shadow=ft.BoxShadow(
|
||||
blur_radius=4,
|
||||
color=ft.Colors.with_opacity(0.05, ft.Colors.BLACK),
|
||||
offset=ft.Offset(0, 2)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def toggle_permission(self, e, user_id, user_email):
|
||||
new_val = 1 if e.control.value else 0
|
||||
try:
|
||||
payload = {"can_create_articles": new_val}
|
||||
response = requests.put(
|
||||
f"{self.base_url}/users/update/{user_id}",
|
||||
json=payload,
|
||||
headers={'Authorization': f'Bearer {self.token}'}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
# Update local data model
|
||||
for exp in self.all_experts:
|
||||
if exp['id'] == user_id:
|
||||
exp['can_create_articles'] = new_val
|
||||
break
|
||||
|
||||
status_msg = "permisiune acordată" if new_val == 1 else "permisiune retrasă"
|
||||
self.show_snack_bar(f"Drepturi actualizate pentru {user_email}: {status_msg}.", ft.Colors.GREEN)
|
||||
else:
|
||||
e.control.value = not e.control.value
|
||||
e.control.update()
|
||||
self.show_snack_bar("Eroare la salvarea permisiunilor.", ft.Colors.RED)
|
||||
except Exception as ex:
|
||||
print(f"Error toggling permission: {ex}")
|
||||
e.control.value = not e.control.value
|
||||
e.control.update()
|
||||
self.show_snack_bar("Eroare de conexiune la server.", ft.Colors.RED)
|
||||
|
||||
def on_search_change(self, e):
|
||||
self.load_experts_list(self.search_bar.value)
|
||||
|
||||
def show_snack_bar(self, message, color):
|
||||
self.page.snack_bar = ft.SnackBar(
|
||||
content=ft.Text(message, color=ft.Colors.WHITE),
|
||||
bgcolor=color,
|
||||
duration=3000
|
||||
)
|
||||
self.page.snack_bar.open = True
|
||||
self.page.update()
|
||||
|
||||
def build(self):
|
||||
self.get_experts()
|
||||
self.load_experts_list()
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text("Configurare Drepturi Publicare", size=20, weight=ft.FontWeight.BOLD),
|
||||
ft.Text(
|
||||
"Permiteți sau blocați dreptul experților de a crea, edita și șterge publicații de pe blog.",
|
||||
size=14,
|
||||
color=ft.Colors.GREY_600
|
||||
),
|
||||
],
|
||||
spacing=5
|
||||
),
|
||||
margin=ft.Margin(0, 0, 0, 10)
|
||||
),
|
||||
ft.Row([self.search_bar]),
|
||||
ft.Divider(height=10, color=ft.Colors.GREY_100),
|
||||
self.experts_list
|
||||
],
|
||||
expand=True
|
||||
),
|
||||
expand=True,
|
||||
padding=20,
|
||||
bgcolor=ft.Colors.GREY_50
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import flet as ft
|
||||
from pages.settings.documente_juridice import DocumenteJuridice
|
||||
from pages.settings.users import UsersSettings
|
||||
from pages.settings.payment_and_subscription import PaymentAndSubscription
|
||||
from pages.settings.articles import ArticlesSettings
|
||||
|
||||
class Settings:
|
||||
def __init__(self, page: ft.Page, home):
|
||||
@@ -11,6 +12,7 @@ class Settings:
|
||||
self.doc_juridice = DocumenteJuridice(self.page)
|
||||
self.users_settings = UsersSettings(self.page)
|
||||
self.payment_and_subscription = PaymentAndSubscription(self.page)
|
||||
self.articles_settings = ArticlesSettings(self.page)
|
||||
|
||||
def build(self):
|
||||
return ft.Tabs(
|
||||
@@ -42,8 +44,9 @@ class Settings:
|
||||
expand=True
|
||||
),
|
||||
ft.Container(
|
||||
content=ft.Text("This is Tab 2"),
|
||||
content=self.articles_settings.build(),
|
||||
alignment=ft.Alignment.CENTER,
|
||||
expand=True
|
||||
),
|
||||
ft.Container(
|
||||
content=ft.Text("This is Tab 3"),
|
||||
|
||||
BIN
documents/Custom/20260616214931_3.png
Normal file
BIN
documents/Custom/20260616214931_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 573 KiB |
BIN
documents/Custom/20260616215606_9.PNG
Normal file
BIN
documents/Custom/20260616215606_9.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
BIN
documents/Custom/20260616215900_9.PNG
Normal file
BIN
documents/Custom/20260616215900_9.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
BIN
documents/Custom/20260616220001_8.png
Normal file
BIN
documents/Custom/20260616220001_8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 676 KiB |
@@ -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.
BIN
server/models/publications/__pycache__/articles.cpython-313.pyc
Normal file
BIN
server/models/publications/__pycache__/articles.cpython-313.pyc
Normal file
Binary file not shown.
146
server/models/publications/articles.py
Normal file
146
server/models/publications/articles.py
Normal 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
|
||||
@@ -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
111
server/routes/articles.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
171
server/templates/editor.html
Normal file
171
server/templates/editor.html
Normal 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>
|
||||
Reference in New Issue
Block a user