diff --git a/submissions/devoteam/Dockerfile b/submissions/devoteam/Dockerfile new file mode 100644 index 00000000..7aa2a6cd --- /dev/null +++ b/submissions/devoteam/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.10-slim + + +# Set up working directory +WORKDIR /all-in-one + + +# Copy requirements and install dependencies +COPY requirements_2.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application +COPY . . +WORKDIR /all-in-one/backend + +# Expose port +EXPOSE 5002 + +# Run the application +CMD ["python", "api.py"] diff --git a/submissions/devoteam/README.md b/submissions/devoteam/README.md index 848a3d9a..63f7e279 100644 --- a/submissions/devoteam/README.md +++ b/submissions/devoteam/README.md @@ -1,29 +1,105 @@ -# 🏆 Final Submission for Devoteam +# hack-day-all-in-one +## Getting started 🔧 +## Step 2 +### Test it -## Project -All-in-one Drive / Slides +You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/) -## Project Description -La Suite tools but improved with AI +### Run Docs locally -## Contributors -@kcap02DVT, @Ghanemamari, @rielzzapps, @VnoelDVT +> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution. -## Code base -(Provide a link to your Git repository) +**Prerequisite** -## Deliverables -(Provide a link to a live demo, if you have one) -(Add screenshots (image, gif or video) and presentation deck to `/assets`) +Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop, then type: -## Key Achievements -(Highlight the main features or breakthroughs) +```shellscript +$ docker -v -## Challenges Overcome -(What was difficult? What did you solve?) +Docker version 20.10.2, build 2291f61 -## Impact -(Who will benefit from your project?) +$ docker compose version -## Next Steps -(If you were to continue, what next steps or developments would you envision?) +Docker Compose version v2.32.4 +``` + +> ⚠️ You may need to run the following commands with `sudo`, but this can be avoided by adding your user to the local `docker` group. + +**Project bootstrap** + +The easiest way to start working on the project is to use [GNU Make](https://www.gnu.org/software/make/): + +```shellscript +$ make bootstrap FLUSH_ARGS='--no-input' +``` + +This command builds the `app` container, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues. + +Your Docker services should now be up and running 🎉 + +You can access to the project by going to . + +You will be prompted to log in. The default credentials are: + +``` +username: impress +password: impress +``` + +📝 Note that if you need to run them afterwards, you can use the eponym Make rule: + +```shellscript +$ make run +``` + +⚠️ For the frontend developer, it is often better to run the frontend in development mode locally. + +To do so, install the frontend dependencies with the following command: + +```shellscript +$ make frontend-development-install +``` + +And run the frontend locally in development mode with the following command: + +```shellscript +$ make run-frontend-development +``` + +To start all the services, except the frontend container, you can use the following command: + +```shellscript +$ make run-backend +``` +## Step 2 + +## Prerequisites +- Python 3.8+ +- pip + +## Installation + +1. After cloning the repository +```bash +cd hack-day-all-in-one/backend +``` + +2. Create and activate virtual environment +```bash +python -m venv venv +venv\Scripts\activate +``` + +3. Install dependencies +```bash +cd .. +pip install -r requirements.txt +``` + + +## Launch + +1. Run the application +```bash +python api.py +``` diff --git a/submissions/devoteam/backend/api.py b/submissions/devoteam/backend/api.py new file mode 100644 index 00000000..324da574 --- /dev/null +++ b/submissions/devoteam/backend/api.py @@ -0,0 +1,439 @@ +import json +import logging +import traceback +import csv +import os +import io +from typing import Dict, Any, Optional +from flask import Flask, request, jsonify, render_template,send_file,Response, stream_with_context +from sentence_transformers import SentenceTransformer +from modules.processing.file import process_file +from modules.processing.clean import clean_text +from modules.processing.chunking import split_text_into_chunks +from modules.embeddings.builder import build_faiss_index, embed_chunks +from modules.embeddings.search import search_best_chunks +from modules.summary.prompt import generate_dynamic_prompt +from modules.summary.generation import stream_response +from modules.doc_generator.parser import parse_html_to_docx +from modules.doc_generator.pdf_creator import parse_html_to_pdf +from modules.generator.generation import generate_stream +from utils import get_client +from io import BytesIO +from routes import router_bp, classifier_bp, generator_bp +from services.pipeline import detect_ministry, detect_doc_type, generate_document +import pdfkit +from jinja2 import Environment, FileSystemLoader, TemplateNotFound +import tempfile + + +app = Flask(__name__, template_folder='./../frontend/templates',static_folder='./../frontend/static') + +app.config['UPLOAD_FOLDER'] = 'uploads' +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) +#Chargement global du modèle SentenceTransformer +embedding_model = SentenceTransformer("all-MiniLM-L6-v2") +last_summary_pdf = None +# Enregistrement des blueprints API +app.register_blueprint(router_bp) +app.register_blueprint(classifier_bp) +app.register_blueprint(generator_bp) + +@app.route('/summary') +def index1(): + return render_template('summary.html') + + +@app.route('/docgen') +def docgen(): + return render_template('docgen_v2.html') + +# Endpoint full pipeline (routage → classification → génération) +@app.post("/api/full_pipeline") +def full_pipeline(): + data = request.get_json(silent=True) + if not data or "text" not in data: + return jsonify(error="Le champ 'text' est requis"), 400 + + text = data["text"] + try: + ministry = detect_ministry(text) + doc_type = detect_doc_type(text, ministry) + document = generate_document(text, ministry, doc_type) + except Exception as e: + return jsonify(error=str(e)), 500 + + return jsonify( + ministry=ministry, + document_type=doc_type, + document=document + ) + +@app.route('/resumer', methods=['POST']) +def resumer_stream(): + language = request.form.get("language", "francais") + files = request.files.getlist("files") + sections_raw = request.form.get('sections') + sections = json.loads(sections_raw) if sections_raw else [] + all_texts = [process_file(f, f.filename) for f in files] + full_text = "\n".join(all_texts) + cleaned = clean_text(full_text) + chunks = split_text_into_chunks(cleaned) + embeddings = embed_chunks(chunks, embedding_model) + index = build_faiss_index(embeddings) + query_embedding = embedding_model.encode(sections_raw, convert_to_numpy=True) + best_indices = search_best_chunks(index, query_embedding) + selected_chunks = [chunks[i] for i in best_indices if i < len(chunks)] + context = "\n\n".join(selected_chunks) + prm = generate_dynamic_prompt(context, sections, language) + client = get_client() + + return Response( + stream_with_context(stream_response(client, prm)() +), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*' + } + ) + + +@app.route('/telecharger-docx', methods=['POST']) +def telecharger_docx(): + # Récupérer le contenu HTML du résumé + resume_html = request.form.get('content', '') + + doc = parse_html_to_docx(resume_html) + # Sauvegarde dans un buffer + file_stream = io.BytesIO() + doc.save(file_stream) + file_stream.seek(0) + + return send_file( + file_stream, + download_name="resume.docx", + as_attachment=True + ) +# ------------------------------------------------------------------ +# Routes pour servir les différentes pages du frontend +# ------------------------------------------------------------------ +@app.route("/") +def tools_selection_page(): + # Page de sélection des prompts (index.html) + return render_template("index.html") + +@app.route("/chatbot") +def index(): + return render_template("chatbot.html") + + +@app.route("/reformulation") +def reformulation_page(): + # Page de reformulation (reformulation.html) + return render_template("reformulation.html") + +@app.route("/generation") +def generation_page(): + # Page de génération (generation.html) + return render_template("generation.html") + +@app.route("/results") +def results_page(): + # Page des résultats (results.html) + return render_template("results.html") + +@app.route("/login") +def login_page(): + # Page des résultats (results.html) + return render_template("login.html") + +@app.route("/backlog") +def backlog_page(): + # Page des résultats (results.html) + return render_template("template_backlog.html") +# ------------------------------------------------------------------ +# Définition des modèles disponibles +# ------------------------------------------------------------------ +models = { + "A": "deepseek-ai/deepseek-r1-distill-qwen-7b", + "B": "deepseek-ai/deepseek-r1-distill-qwen-14b", + "C": "deepseek-ai/deepseek-r1-distill-qwen-32b", + "D": "qwen/qwq-32b", # Exemple de nom pour le modèle D + "E": "mistralai/mistral-small-24b-instruct", # Exemple de nom pour le modèle E + "F": "meta/llama-3.1-405b-instruct", # Exemple de nom pour le modèle F + "G": "meta/llama-3.2-1b-instruct", + "H": "meta/llama-3.2-3b-instruct", + "I": "google/gemma-2b", + "J": "deepseek-ai/deepseek-r1-distill-llama-8b", + "K": "tiiuae/falcon3-7b-instruct", + "L": "meta/llama3-70b-instruct", + "M": "writer/palmyra-creative-122b" +} + +# ------------------------------------------------------------------ +# Fonctions de validation et d'analyse du JSON d'évaluation +# ------------------------------------------------------------------ +def validate_evaluation_json(evaluation_data: Dict[str, Any]) -> bool: + if not isinstance(evaluation_data, dict): + raise PromptReformulationError("Le format des données d'évaluation est invalide (dictionnaire attendu)") + if "évaluation" not in evaluation_data: + raise PromptReformulationError("Clé 'évaluation' manquante dans les données d'évaluation") + if not isinstance(evaluation_data["évaluation"], list): + raise PromptReformulationError("Le format de l'évaluation est invalide (liste attendue)") + if len(evaluation_data["évaluation"]) == 0: + raise PromptReformulationError("La liste d'évaluation est vide") + if "note" not in evaluation_data["évaluation"][-1]: + raise PromptReformulationError("Note globale manquante dans le dernier élément d'évaluation") + for i, criterion in enumerate(evaluation_data["évaluation"][:-1]): + if "critère" not in criterion or "note" not in criterion: + raise PromptReformulationError(f"Le critère à l'indice {i} n'a pas le format attendu") + return True + + +# ------------------------------------------------------------------ +# Fonction de génération de reformulation CoT +# ------------------------------------------------------------------ +def generate_cot_prompt_stream(prompt: str, model: str, temperature: float = 0.6, + top_p: float = 0.7, top_k: Optional[int] = None): + client = get_client() + cot_request = f""" + Transformez la requête suivante en une version explicite avec une approche "Chain of Thought" (CoT) avant d'y répondre. + Encadrez la partie raisonnement interne avec ... : + "{prompt}" + """ + + cot_completion = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": cot_request}], + temperature=temperature, + top_p=top_p, + max_tokens=1024, + stream=True + ) + + capture = False + think_buffer = "" + + for chunk in cot_completion: + content = chunk.choices[0].delta.content if hasattr(chunk.choices[0].delta, 'content') else None + if content: + while content: + print(f"[BACKEND] Content chunk: {content}") # Log du contenu + if not capture: + start_index = content.find("") + if start_index != -1: + yield content[:start_index] # Stream la partie avant + content = content[start_index + len(""):] + capture = True + else: + yield content # Continue à streamer normalement + break + else: + end_index = content.find("") + if end_index != -1: + think_buffer += content[:end_index] + content = content[end_index + len(""):] + capture = False + else: + think_buffer += content + break + + + +# ------------------------------------------------------------------ +# Fonction pour stocker les résultats dans un CSV +# ------------------------------------------------------------------ +def store_results(row: Dict[str, Any], filename="results.csv"): + file_exists = os.path.isfile(filename) + fieldnames = [ + "prompt_original", "model_cot", "model_response", "temperature", "top_p", "top_k", + "evaluation_model", "reformulation_think_content", "reformulation_main_content", + "response", "evaluation_details", "hallucination_phrases", "hallucination_probabilities", + "hallucination_explanations", "weakest_criterion", "reformulated_prompt","inference_time_s", + "num_tokens","emissions_gCO2" + ] + with open(filename, "a", newline='', encoding="utf-8") as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + if not file_exists: + writer.writeheader() + writer.writerow(row) + + +@app.route("/reformulate", methods=["POST"]) +def reformulate(): + data = request.get_json() + if not data or "original_prompt" not in data: + return jsonify({"error": "Le champ 'original_prompt' est requis."}), 400 + + original_prompt = data["original_prompt"] + temperature = float(data.get("temperature", 0.6)) + top_p = float(data.get("top_p", 0.7)) + top_k = int(data.get("top_k", 40)) + model_key = data.get("model_cot", "A") + model_used = models.get(model_key, "meta/llama-3.2-1b-instruct") + + def generate(): + try: + for chunk in generate_cot_prompt_stream( + original_prompt, + model=model_used, + temperature=temperature, + top_p=top_p, + top_k=top_k + ): + yield f"data: {chunk}\n\n" + except Exception as e: + yield f"data: [ERROR] {str(e)}\n\n" + + return Response(stream_with_context(generate()), mimetype="text/event-stream") + +# ------------------------------------------------------------------ +# Routes de l'API Flask pour la génération, la reformulation, l'itération, etc. +# ------------------------------------------------------------------ +@app.route("/generate", methods=["POST"]) +def generate(): + data = request.get_json() + if not data or "prompt" not in data: + return jsonify({"error": "Le champ 'prompt' est requis."}), 400 + + prompt = data["prompt"] + print(f"[BACKEND] Prompt reçu : {prompt}") # Log du prompt reçu + if "backlog" in prompt: + prompt=prompt + """Génère une réponse sous forme de tableau Markdown basé sur la requête suivante. Le tableau doit inclure les colonnes "ID", "User Story", "Priorité", "Estimation" et "Statut". Fournis au moins une ligne de données pertinente pour la requête. Ne donne que le tableau Markdown, sans texte supplémentaire avant ou après. + + Exemple de format de tableau Markdown attendu : + + | ID | User Story | Priorité | Estimation | Statut | + |------|----------------------------------------------------------------------|----------|------------|---------| + | US01 | En tant qu'utilisateur, je veux que mes messages soient corrigés... | Haute | 8 pts | À faire | + | US02 | En tant qu'utilisateur, je veux que mes messages soient reformulés...| Moyenne | 5 pts | À faire | + """ + print("yessssssssssssssssssssssssss") + print(prompt) + prompt = prompt + "Ne détaille pas ton processus de réflexion et réponds uniquement en français." + #print(prompt) + model_response_key = data.get("model_response", "A") + temperature = data.get("temperature", 0.6) + top_p = data.get("top_p", 0.7) + top_k = data.get("top_k", 40) + model_response = models.get(model_response_key, "meta/llama3-8b-instruct") + client = get_client() + + + return Response( + stream_with_context(generate_stream(client,prompt, model_response, temperature, top_p)), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*' + } + ) + + + + +config = pdfkit.configuration(wkhtmltopdf='C:/wkhtmltox/bin/wkhtmltopdf.exe') +@app.route('/generate-pdf', methods=['POST']) +def generate_pdf(): + try: + print("Début de la génération du PDF") + # Si on reçoit du JSON (application/json) + if request.is_json: + print("Requête JSON détectée") + data = request.get_json() + print(f"Données reçues: {data}") + + if 'items' in data: + print("Génération du backlog") + template_data = { + 'title': data.get('title', 'Product Backlog'), + 'items': data.get('items', []) + } + # Configuration du chemin de base pour les templates + base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../frontend/templates')) + print(f"Chemin des templates: {base_dir}") + # Charger le template HTML + env = Environment(loader=FileSystemLoader(base_dir)) + template = env.get_template('template_backlog.html') + + # Rendre le template avec les données + html_content = template.render(template_data) + print("Template rendu avec succès") + + # Configuration de wkhtmltopdf + # Chemin Windows + # Chemin absolu à utiliser comme "racine" des chemins relatifs (ex: ../static/...) + base_url = base_dir # Cela remonte à `frontend/templates`, donc `../static` pointe vers `frontend/static` + + options = { + 'enable-local-file-access': '', + 'quiet': '' + } + # Générer le PDF + pdf_bytes = pdfkit.from_string( + html_content, + False, + configuration=config, + options=options + ) + print("PDF généré avec succès") + + file_stream = io.BytesIO(pdf_bytes) + file_stream.seek(0) + return send_file( + file_stream, + download_name="product_backlog.pdf", + as_attachment=True, + mimetype='application/pdf' + ) + + + elif 'html' in data: + print("Génération du PDF à partir d'un HTML") + # Assurez-vous que 'html' est bien extrait des données + html_content = data.get('html', '') # Utilisez data.get('html', '') pour gérer le cas où 'html' est manquant + if not html_content: + return jsonify({'error': 'Le champ "html" est manquant ou vide dans la requête.'}), 400 + + # **Correction pour l'encodage :** + # Ajouter la balise meta charset au début du HTML si elle n'est pas déjà présente + if '') + if head_end != -1: + html_content = html_content[:head_end] + '' + html_content[head_end:] + else: # Ajouter après si pas de (moins idéal mais fonctionnel) + html_content = '' + html_content + with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmpfile: + # Utiliser html_content ici + options = { + 'enable-local-file-access': '', # Permet l'accès aux fichiers locaux + 'encoding': 'UTF-8', # Option d'encodage répétée pour plus de sûreté + 'no-outline': None, + 'margin-top': '10mm', + 'margin-right': '10mm', + 'margin-bottom': '10mm', + 'margin-left': '10mm', + 'footer-center': 'Page [page] sur [topage]', # Ajout de la numérotation des pages + 'footer-font-size': '8', + 'footer-spacing': '5' + } + pdfkit.from_string(html_content, tmpfile.name,configuration=config,options=options) + return send_file(tmpfile.name, as_attachment=True, download_name="analyse_risques.pdf") + + # Si la requête est JSON mais ne contient ni 'items' ni 'html' + else: + print("Requête JSON sans données valides (items ou html)") + return jsonify({'error': 'Requête JSON invalide: Le champ "items" ou "html" est requis.'}), 400 + + except Exception as e: + print(f"Erreur lors de la génération du PDF: {str(e)}") + print(f"Traceback: {traceback.format_exc()}") + return jsonify({'error': str(e)}), 500 + +if __name__ == "__main__": + app.run(host="0.0.0.0", debug=True, port=5002) \ No newline at end of file diff --git a/submissions/devoteam/backend/config.py b/submissions/devoteam/backend/config.py new file mode 100644 index 00000000..a4d5d428 --- /dev/null +++ b/submissions/devoteam/backend/config.py @@ -0,0 +1,24 @@ +import os +from dotenv import load_dotenv + + +load_dotenv() + +class Config: + OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://integrate.api.nvidia.com/v1") + + #ROUTER_MODEL = os.getenv("ROUTER_MODEL", "mistralai/mistral-small-24b-instruct") + #CLASSIFIER_MODEL = os.getenv("CLASSIFIER_MODEL", "mistralai/mistral-small-24b-instruct") + #GENERATOR_MODEL = os.getenv("GENERATOR_MODEL", "mistralai/mistral-small-24b-instruct") + + ROUTER_MODEL = os.getenv("ROUTER_MODEL", "meta/llama-3.2-1b-instruct") + CLASSIFIER_MODEL = os.getenv("CLASSIFIER_MODEL", "meta/llama-3.2-1b-instruct") + GENERATOR_MODEL = os.getenv("GENERATOR_MODEL", "meta/llama-3.2-1b-instruct") + + + TEMPERATURE_ROUTER = float(os.getenv("TEMP_ROUTER", 0.0)) + TEMPERATURE_CLASSIF = float(os.getenv("TEMP_CLASSIF", 0.2)) + TEMPERATURE_GEN = float(os.getenv("TEMP_GEN", 0.2)) + + API_KEY_NVIDIA = os.getenv("API_KEY_NVIDIA") diff --git a/submissions/devoteam/backend/modules/__init__.py b/submissions/devoteam/backend/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/backend/modules/doc_generator/parser.py b/submissions/devoteam/backend/modules/doc_generator/parser.py new file mode 100644 index 00000000..249624eb --- /dev/null +++ b/submissions/devoteam/backend/modules/doc_generator/parser.py @@ -0,0 +1,23 @@ +from bs4 import BeautifulSoup +from docx import Document + +def parse_html_to_docx(content_html: str) -> Document: + soup = BeautifulSoup(content_html, 'html.parser') + doc = Document() + doc.add_heading('Résumé généré', 0) + + for element in soup.contents: + if element.name == 'h3': + doc.add_paragraph(element.get_text(strip=True), style='Heading 2') + elif element.name == 'b': + p = doc.add_paragraph() + p.add_run(element.get_text(strip=True)).bold = True + elif element.name == 'br': + doc.add_paragraph() # ligne vide + elif element.name is None: + if element.strip(): + doc.add_paragraph(element.strip()) + else: + doc.add_paragraph(element.get_text(strip=True)) + + return doc diff --git a/submissions/devoteam/backend/modules/doc_generator/pdf_creator.py b/submissions/devoteam/backend/modules/doc_generator/pdf_creator.py new file mode 100644 index 00000000..d0a6ed94 --- /dev/null +++ b/submissions/devoteam/backend/modules/doc_generator/pdf_creator.py @@ -0,0 +1,56 @@ +from bs4 import BeautifulSoup +from fpdf import FPDF +import os +class PDF(FPDF): + def __init__(self): + super().__init__() + # ✅ Calcule le chemin ABSOLU vers la police depuis CE fichier + font_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "../../../../all-in-one/frontend/fonts/DejaVuSans.ttf") + ) + + # ✅ Optionnel : aide au débogage + print("Font path:", font_path) + # Vérification optionnelle + if not os.path.exists(font_path): + raise FileNotFoundError(f"Font file not found at {font_path}") + self.add_font("DejaVu", "", font_path, uni=True) + self.add_font("DejaVu", "B", font_path, uni=True) + self.set_font("DejaVu", "", 12) + + def header(self): + self.set_font("DejaVu", "B", 16) + self.cell(0, 10, "Réponse", ln=True, align="C") + self.ln(10) + + def chapter_title(self, title): + self.set_font("DejaVu", "B", 14) + self.set_text_color(50, 50, 50) + self.cell(0, 10, title, ln=True) + self.ln(5) + + def paragraph(self, text, bold=False): + self.set_font("DejaVu", "B" if bold else "", 12) + self.set_text_color(0, 0, 0) + self.multi_cell(0, 10, text) + self.ln(1) + +def parse_html_to_pdf(content_html: str) -> FPDF: + soup = BeautifulSoup(content_html, 'html.parser') + pdf = PDF() + pdf.add_page() + + for element in soup.contents: + if element.name == 'h3': + pdf.chapter_title(element.get_text(strip=True)) + elif element.name == 'b': + pdf.paragraph(element.get_text(strip=True), bold=True) + elif element.name == 'br': + pdf.ln(5) + elif element.name is None: + if element.strip(): + pdf.paragraph(element.strip()) + else: + pdf.paragraph(element.get_text(strip=True)) + + return pdf # ⬅️ on retourne l'objet PDF diff --git a/submissions/devoteam/backend/modules/embeddings/__init__.py b/submissions/devoteam/backend/modules/embeddings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/backend/modules/embeddings/builder.py b/submissions/devoteam/backend/modules/embeddings/builder.py new file mode 100644 index 00000000..cf500806 --- /dev/null +++ b/submissions/devoteam/backend/modules/embeddings/builder.py @@ -0,0 +1,11 @@ +import faiss + +def embed_chunks(chunks, model): + embeddings = model.encode(chunks, convert_to_numpy=True) + return embeddings + +def build_faiss_index(embeddings): + d = embeddings.shape[1] + index = faiss.IndexFlatL2(d) + index.add(embeddings) + return index \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/embeddings/search.py b/submissions/devoteam/backend/modules/embeddings/search.py new file mode 100644 index 00000000..9372a4b7 --- /dev/null +++ b/submissions/devoteam/backend/modules/embeddings/search.py @@ -0,0 +1,4 @@ +import numpy as np +def search_best_chunks(index, query_embedding, k=8): + distances, indices = index.search(np.array([query_embedding]), k) + return indices[0] \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/extraction/__init__.py b/submissions/devoteam/backend/modules/extraction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/backend/modules/extraction/docx.py b/submissions/devoteam/backend/modules/extraction/docx.py new file mode 100644 index 00000000..873c9ee1 --- /dev/null +++ b/submissions/devoteam/backend/modules/extraction/docx.py @@ -0,0 +1,10 @@ +import docx +def extract_text_from_docx(file_obj): + text = "" + try: + doc = docx.Document(file_obj) + for para in doc.paragraphs: + text += para.text + "\n" + except Exception as e: + raise ValueError(f"Erreur lors de l'extraction du DOCX: {e}") + return text \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/extraction/images.py b/submissions/devoteam/backend/modules/extraction/images.py new file mode 100644 index 00000000..64bc4f1b --- /dev/null +++ b/submissions/devoteam/backend/modules/extraction/images.py @@ -0,0 +1,8 @@ +def extract_text_from_image(file_obj): + text = "" + try: + image = Image.open(file_obj) + text = pytesseract.image_to_string(image) + except Exception as e: + raise ValueError(f"Erreur lors de l'extraction de l'image: {e}") + return text \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/extraction/pdf.py b/submissions/devoteam/backend/modules/extraction/pdf.py new file mode 100644 index 00000000..1d0c0caa --- /dev/null +++ b/submissions/devoteam/backend/modules/extraction/pdf.py @@ -0,0 +1,10 @@ +import fitz +def extract_text_from_pdf(file_obj): + text = "" + try: + doc = fitz.open(stream=file_obj.read(), filetype="pdf") + for page in doc: + text += page.get_text() + "\n" + except Exception as e: + raise ValueError(f"Erreur lors de l'extraction du PDF: {e}") + return text \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/extraction/pptx.py b/submissions/devoteam/backend/modules/extraction/pptx.py new file mode 100644 index 00000000..500f26b7 --- /dev/null +++ b/submissions/devoteam/backend/modules/extraction/pptx.py @@ -0,0 +1,12 @@ +import pptx +def extract_text_from_pptx(file_obj): + text = "" + try: + presentation = pptx.Presentation(file_obj) + for slide in presentation.slides: + for shape in slide.shapes: + if hasattr(shape, "text"): + text += shape.text + "\n" + except Exception as e: + raise ValueError(f"Erreur lors de l'extraction du PPTX: {e}") + return text \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/extraction/txt.py b/submissions/devoteam/backend/modules/extraction/txt.py new file mode 100644 index 00000000..8616764c --- /dev/null +++ b/submissions/devoteam/backend/modules/extraction/txt.py @@ -0,0 +1,7 @@ +def extract_text_from_txt(file_obj): + try: + text = file_obj.read().decode("utf-8") + except Exception as e: + raise ValueError(f"Erreur lors de l'extraction du TXT: {e}") + text = "" + return text \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/extraction/xlsx.py b/submissions/devoteam/backend/modules/extraction/xlsx.py new file mode 100644 index 00000000..548242ad --- /dev/null +++ b/submissions/devoteam/backend/modules/extraction/xlsx.py @@ -0,0 +1,9 @@ +import pandas as pd +def extract_text_from_xlsx(file_obj): + text = "" + try: + df = pd.read_excel(file_obj) + text = df.to_string() + except Exception as e: + raise ValueError(f"Erreur lors de l'extraction du XLSX: {e}") + return text \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/extraction/zip.py b/submissions/devoteam/backend/modules/extraction/zip.py new file mode 100644 index 00000000..01d91f7d --- /dev/null +++ b/submissions/devoteam/backend/modules/extraction/zip.py @@ -0,0 +1,13 @@ +import zipfile +def extract_text_from_zip(file_obj): + full_text = "" + try: + with zipfile.ZipFile(file_obj) as z: + for name in z.namelist(): + ext = name.split('.')[-1].lower() + if ext in ["pdf", "docx", "pptx", "xlsx", "txt", "jpg", "jpeg", "png"]: + with z.open(name) as f: + full_text += process_file(f, name) + "\n" + except Exception as e: + raise ValueError(f"Erreur lors de l'extraction du zip: {e}") + return full_text \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/generator/__init__.py b/submissions/devoteam/backend/modules/generator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/backend/modules/generator/emission.py b/submissions/devoteam/backend/modules/generator/emission.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/backend/modules/generator/evaluation.py b/submissions/devoteam/backend/modules/generator/evaluation.py new file mode 100644 index 00000000..67b7f2f6 --- /dev/null +++ b/submissions/devoteam/backend/modules/generator/evaluation.py @@ -0,0 +1,175 @@ +from typing import Dict, Any +from utils import get_client +import json +import re +import logging + +# Configuration du logger +logger = logging.getLogger(__name__) + +# ------------------------------------------------------------------ +# Exception personnalisée pour la reformulation de prompt +# ------------------------------------------------------------------ +class PromptReformulationError(Exception): + """Exception personnalisée pour les erreurs de reformulation de prompt""" + pass + +# ------------------------------------------------------------------ +# Fonction d'évaluation de réponse +# ------------------------------------------------------------------ +def evaluate_response(question: str, response: str) -> str: + client = get_client() + evaluation_model = "deepseek-ai/deepseek-r1-distill-qwen-14b" + evaluation_prompt = f""" + Vous êtes un évaluateur expert chargé d'évaluer une réponse selon les critères suivants. + + 1. **Pertinence contextuelle** (0-25) : La réponse est-elle directement liée à la question ? + 2. **Exhaustivité** (0-25) : La réponse couvre-t-elle tous les aspects essentiels sans omission ? + 3. **Conformité aux bonnes pratiques** (0-25) : Suit-elle les normes et méthodes reconnues ? + 4. **Absence d'erreurs critiques** (0-25) : Contient-elle des erreurs factuelles ou logiques ? + + **⚠️ TRES IMPORTANT** : + - Aucune réponse ne peut atteindre 100/100. Sanctionnez toute imprécision ou exagération. + - La note globale doit être strictement la somme des critères ci-dessus. + + **Pour le fact-checking :** + - Chaque phrase sans source ou preuve concrète doit être marquée avec un risque d'hallucination de 10-30%. + - Pour chaque phrase suspecte, précisez le type d'hallucination parmi : + "Pertinence contextuelle", "Exhaustivité", "Conformité aux bonnes pratiques", ou "Absence d'erreurs critiques". + + **Question posée :** + {question} + + **Réponse à évaluer :** + {response} + + **Format de sortie attendu (strictement JSON) :** + ```json + {{ + "évaluation": [ + {{"critère": "Pertinence contextuelle", "note": , "explication": ""}}, + {{"critère": "Exhaustivité", "note": , "explication": ""}}, + {{"critère": "Conformité aux bonnes pratiques", "note": , "explication": ""}}, + {{"critère": "Absence d'erreurs critiques", "note": , "explication": ""}}, + {{"note": }} + ], + "fact-checking": [ + {{ + "phrase": "Phrase 1 suspecte", + "hallucination_prob": <10-30>, + "explication": "" + }}, + {{ + "phrase": "Phrase 2 suspecte", + "hallucination_prob": <10-30>, + "explication": "" + }}, + {{ + "phrase": "Phrase 3 suspecte", + "hallucination_prob": <10-30>, + "explication": "" + }} + ] + }} + ``` + Répondez uniquement en JSON sans texte supplémentaire. + """ + try: + eval_completion = client.chat.completions.create( + model=evaluation_model, + messages=[{"role": "user", "content": evaluation_prompt}], + temperature=0.8, + top_p=0.7, + max_tokens=4096, + stream=False + ) + raw_response = eval_completion.choices[0].message.content + print(f"Réponse brute du modèle: {raw_response}") + + cleaned_response = clean_json_response(raw_response) + print(f"Réponse nettoyée: {cleaned_response}") + + try: + json_response = json.loads(cleaned_response) + evaluation = json_response.get("évaluation", []) + total = 0 + for critere in evaluation: + if "critère" in critere: + try: + total += float(critere.get("note", 0)) + except (ValueError, TypeError): + logger.warning(f"Note invalide pour le critère {critere.get('critère')}: {critere.get('note')}") + pass + + # On ajoute "critère": "Total" pour remplacer "undefined" dans l'affichage + note_globale = { + "critère": "Total", + "note": total, + "explication": "Somme des notes des critères d'évaluation." + } + + evaluation = [crit for crit in evaluation if "critère" in crit] + [note_globale] + json_response["évaluation"] = evaluation + + return json.dumps(json_response) + except json.JSONDecodeError as e: + logger.error(f"Erreur de décodage JSON: {str(e)}") + logger.error(f"JSON invalide: {cleaned_response}") + raise PromptReformulationError(f"JSON invalide retourné par le modèle d'évaluation: {str(e)}") + except Exception as e: + logger.error(f"Erreur lors de l'évaluation: {str(e)}", exc_info=True) + raise PromptReformulationError(f"Erreur lors de l'évaluation: {str(e)}") + + +def predict_quality(evaluation: Dict[str, Any]) -> Dict[str, Any]: + global_note = None + for crit in evaluation.get("évaluation", []): + if crit.get("critère") == "Total": + try: + global_note = float(crit.get("note", 0)) + except (ValueError, TypeError): + global_note = 0 + + if global_note is None: + global_note = 0 + if global_note >= 90: + quality = "Excellent" + confidence = 0.95 + elif global_note >= 75: + quality = "Bon" + confidence = 0.90 + elif global_note >= 50: + quality = "Moyen" + confidence = 0.80 + elif global_note >= 25: + quality = "À améliorer" + confidence = 0.70 + else: + quality = "Insuffisant" + confidence = 0.65 + return { + "quality_prediction": quality, + "confidence": confidence, + "global_note": global_note + } +# ------------------------------------------------------------------ +# Fonction utilitaire pour nettoyer une réponse JSON +# ------------------------------------------------------------------ + + + +logger = logging.getLogger(__name__) + + + +def clean_json_response(response_text: str) -> str: + # Supprimer uniquement les balises (pas leur contenu) + response_text = response_text.replace("", "").replace("", "") + + # Extraire le JSON si présent entre ```json ... ``` + match = re.search(r"```json\s*(.*?)\s*```", response_text, re.DOTALL) + if match: + return match.group(1).strip() + + # Sinon, retourner tout le texte nettoyé + return response_text.strip() diff --git a/submissions/devoteam/backend/modules/generator/generation.py b/submissions/devoteam/backend/modules/generator/generation.py new file mode 100644 index 00000000..8fbd8f2a --- /dev/null +++ b/submissions/devoteam/backend/modules/generator/generation.py @@ -0,0 +1,106 @@ +from typing import Optional +import json +import logging +import time +from modules.generator.evaluation import evaluate_response, predict_quality +# Configuration du logger +logger = logging.getLogger(__name__) + +# ------------------------------------------------------------------ +# Fonction de génération de réponse via modèle +# ------------------------------------------------------------------ +def generate_model_response(client, prompt: str, model: str, temperature: float = 0.6, + top_p: float = 0.7, top_k: Optional[int] = None): + try: + params = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": temperature, + "top_p": top_p, + "max_tokens": 4096, + "stream": True + } + if top_k is not None: + params["top_k"] = top_k + + completion = client.chat.completions.create(**params) + + full_response = "" + in_think_block = False + for chunk in completion: + content = chunk.choices[0].delta.content if hasattr(chunk.choices[0].delta, 'content') else None + if content: + # Vérifier si on entre dans un bloc think + if "" in content: + in_think_block = True + # Ne pas inclure le contenu avant + content = content.split("")[0] + + # Vérifier si on sort d'un bloc think + if "" in content: + in_think_block = False + # Ne garder que le contenu après + content = content.split("")[1] + + # N'ajouter le contenu que si on n'est pas dans un bloc think + if not in_think_block and content: + # Envoyer la réponse complète une seule fois + yield json.dumps({ + "type": "generating", + "message": content + }) + + + + except Exception as e: + # Logger l'erreur dans le backend + logger.error(f"Erreur lors de la génération: {str(e)}", exc_info=True) + # Envoyer l'erreur au frontend dans le bon format + yield json.dumps({ + "type": "error", + "message": str(e) + }) + +def generate_stream(client,prompt, model_response, temperature, top_p): + total_time = 0 + total_calls = 0 + full_response = "" + + try: + start_time = time.time() + for chunk_json in generate_model_response(client,prompt, model_response, temperature, top_p): + chunk = json.loads(chunk_json) + full_response += chunk["message"] + yield f"data: {json.dumps({'type': 'generating', 'content': chunk['message']})}\n\n" + + + # Signal de fin de génération + yield f"data: {json.dumps({'type': 'completed', 'content': full_response})}\n\n" + + # Générer et envoyer l'évaluation + try: + evaluation_json_str = evaluate_response(prompt, full_response) + evaluation_obj = json.loads(evaluation_json_str) + quality_obj = predict_quality(evaluation_obj) + + yield f"data: {json.dumps({'type': 'evaluation', 'evaluation': evaluation_obj, 'quality': quality_obj})}\n\n" + + except Exception as eval_error: + yield f"data: {json.dumps({'type': 'error', 'message': str(eval_error)})}\n\n" + # Calcul empreinte carbone + duration = time.time() - start_time + try: + + enc = tiktoken.encoding_for_model("gpt-3.5-turbo") + total_tokens = len(enc.encode(prompt + full_response)) + except Exception: + total_tokens = len((prompt + full_response).split()) + + emissions_per_second = 0.000117 + emissions_per_token = 0.000021 + emissions = round((duration * emissions_per_second + total_tokens * emissions_per_token), 5) + + yield f"data: {json.dumps({'type': 'emissions', 'emissions_gCO2': emissions})}\n\n" + + except Exception as e: + yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" diff --git a/submissions/devoteam/backend/modules/processing/__init__.py b/submissions/devoteam/backend/modules/processing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/backend/modules/processing/chunking.py b/submissions/devoteam/backend/modules/processing/chunking.py new file mode 100644 index 00000000..86a84d1f --- /dev/null +++ b/submissions/devoteam/backend/modules/processing/chunking.py @@ -0,0 +1,12 @@ +def split_text_into_chunks(text, chunk_size=500, overlap=50): + words = text.split() + chunks = [] + start = 0 + while start < len(words): + end = start + chunk_size + chunk = " ".join(words[start:end]) + chunks.append(chunk) + if end >= len(words): + break + start = end - overlap + return chunks \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/processing/clean.py b/submissions/devoteam/backend/modules/processing/clean.py new file mode 100644 index 00000000..fe11d297 --- /dev/null +++ b/submissions/devoteam/backend/modules/processing/clean.py @@ -0,0 +1,8 @@ +from unidecode import unidecode +import re +def clean_text(text): + text = text.lower() + text = unidecode(text) + text = re.sub(r"[^a-z0-9\s.,;:?!'-]", " ", text) + text = re.sub(r'\s+', ' ', text) + return text \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/processing/file.py b/submissions/devoteam/backend/modules/processing/file.py new file mode 100644 index 00000000..9db9a99d --- /dev/null +++ b/submissions/devoteam/backend/modules/processing/file.py @@ -0,0 +1,23 @@ +from modules.extraction.pdf import extract_text_from_pdf +from modules.extraction.docx import extract_text_from_docx +from modules.extraction.pptx import extract_text_from_pptx +from modules.extraction.xlsx import extract_text_from_xlsx +from modules.extraction.txt import extract_text_from_txt +from modules.extraction.zip import extract_text_from_zip +from modules.extraction.images import extract_text_from_image +extractors = { + "pdf": extract_text_from_pdf, + "docx": extract_text_from_docx, + "pptx": extract_text_from_pptx, + "xlsx": extract_text_from_xlsx, + "txt": extract_text_from_txt, + "zip": extract_text_from_zip, + "jpg": extract_text_from_image, + "jpeg": extract_text_from_image, + "png": extract_text_from_image, +} + +def process_file(file_obj, file_name): + ext = file_name.split('.')[-1].lower() + func = extractors.get(ext) + return func(file_obj) if func else "" \ No newline at end of file diff --git a/submissions/devoteam/backend/modules/reformulation/reformulate.py b/submissions/devoteam/backend/modules/reformulation/reformulate.py new file mode 100644 index 00000000..cb4cefff --- /dev/null +++ b/submissions/devoteam/backend/modules/reformulation/reformulate.py @@ -0,0 +1,79 @@ +from typing import Optional +import json +import logging +import time +import tiktoken +# Configuration du logger +logger = logging.getLogger(__name__) + +# ------------------------------------------------------------------ +# Fonction de reformulation de réponse via modèle +# ------------------------------------------------------------------ +def reformulate_model_response(client, prompt: str, model: str, temperature: float = 0.6, + top_p: float = 0.7, top_k: Optional[int] = None): + try: + params = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": temperature, + "top_p": top_p, + "max_tokens": 4096, + "stream": True + } + if top_k is not None: + params["top_k"] = top_k + + completion = client.chat.completions.create(**params) + + full_response = "" + for chunk in completion: + content = chunk.choices[0].delta.content if hasattr(chunk.choices[0].delta, 'content') else None + if content: + full_response += content + yield json.dumps({ + "type": "reformulating", + "message": content + }) + + except Exception as e: + # Logger l'erreur dans le backend + logger.error(f"Erreur lors de la reformulation: {str(e)}", exc_info=True) + # Envoyer l'erreur au frontend dans le bon format + yield json.dumps({ + "type": "error", + "message": str(e) + }) + +def reformulate_stream(client,prompt, model_response, temperature, top_p): + total_time = 0 + total_calls = 0 + full_response = "" + + try: + start_time = time.time() + for chunk_json in reformulate_model_response(client,prompt, model_response, temperature, top_p): + chunk = json.loads(chunk_json) + full_response += chunk["message"] + yield f"data: {json.dumps({'type': 'reformulating', 'content': chunk['message']})}\n\n" + + + # Signal de fin de génération + yield f"data: {json.dumps({'type': 'completed', 'content': full_response})}\n\n" + + # Calcul empreinte carbone + duration = time.time() - start_time + try: + + enc = tiktoken.encoding_for_model("gpt-3.5-turbo") + total_tokens = len(enc.encode(prompt + full_response)) + except Exception: + total_tokens = len((prompt + full_response).split()) + + emissions_per_second = 0.000117 + emissions_per_token = 0.000021 + emissions = round((duration * emissions_per_second + total_tokens * emissions_per_token), 5) + + yield f"data: {json.dumps({'type': 'emissions', 'emissions_gCO2': emissions})}\n\n" + + except Exception as e: + yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" diff --git a/submissions/devoteam/backend/modules/summary/__init__.py b/submissions/devoteam/backend/modules/summary/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/backend/modules/summary/generation.py b/submissions/devoteam/backend/modules/summary/generation.py new file mode 100644 index 00000000..7b3a0198 --- /dev/null +++ b/submissions/devoteam/backend/modules/summary/generation.py @@ -0,0 +1,41 @@ +import time +import json +import tiktoken +EMISSIONS_PER_SECOND = 0.000117 +EMISSIONS_PER_TOKEN = 0.000021 + +def stream_response(client, prompt, model_name="mistralai/mistral-small-24b-instruct"): + def generate(): + try: + start_time = time.time() + full_response = "" + + # Génération du résumé + completion = client.chat.completions.create( + model=model_name, + messages=[{"role": "user", "content": prompt}], + stream=True + ) + + for chunk in completion: + content = chunk.choices[0].delta.content if hasattr(chunk.choices[0].delta, 'content') else None + if content: + full_response += content + yield f"data: {json.dumps({'type': 'generating', 'content': content})}\n\n" + + # Calcul des émissions carbone + duration = time.time() - start_time + try: + enc = tiktoken.encoding_for_model("gpt-3.5-turbo") + total_tokens = len(enc.encode(prompt + full_response)) + except Exception: + total_tokens = len((prompt + full_response).split()) + + emissions = round((duration * EMISSIONS_PER_SECOND + total_tokens * EMISSIONS_PER_TOKEN), 5) + yield f"data: {json.dumps({'type': 'emissions', 'emissions_gCO2': emissions})}\n\n" + + except Exception as e: + error_message = str(e) + print(f"Erreur dans la génération: {error_message}") + yield f"data: {json.dumps({'type': 'error', 'message': error_message})}\n\n" + return generate diff --git a/submissions/devoteam/backend/modules/summary/prompt.py b/submissions/devoteam/backend/modules/summary/prompt.py new file mode 100644 index 00000000..1a122160 --- /dev/null +++ b/submissions/devoteam/backend/modules/summary/prompt.py @@ -0,0 +1,12 @@ +# --- PROMPT DYNAMIQUE --- +def generate_dynamic_prompt(context, sections, language='francais'): + if language=='francais': + header = 'Tu es un assistant spécialisé en recherche scientifique. Génère un résumé structuré et sous forme des paragraphes avec :\n- Introduction\n' + for s in sections: header += f'- {s}\n' + header += '- Conclusion\n\nContenu extrait :\n' + context + '\n\nRédige le résumé suivant cette structure.' + else: + header = 'You are a scientific assistant. Generate a structured summary and in the form of paragraphs with :\n- Introduction\n' + for s in sections:header += f'- {s}\n' + header += '- Conclusion\n\nExtracted content:\n' + context + '\n\nWrite the summary following this structure.' + + return header \ No newline at end of file diff --git a/submissions/devoteam/backend/prompts/classifier.yaml b/submissions/devoteam/backend/prompts/classifier.yaml new file mode 100644 index 00000000..8d900852 --- /dev/null +++ b/submissions/devoteam/backend/prompts/classifier.yaml @@ -0,0 +1,12 @@ +template: | + Cette demande concerne le ministère de {{ ministry }}. + Quel type de document doit être généré parmi : {{ doc_types|join(", ") }}. + Réponds seulement par le type exact de document. + +doc_types: + - lettre formelle + - mail administratif + - rapport + - dossier de subvention + - dossier complet + - note de service \ No newline at end of file diff --git a/submissions/devoteam/backend/prompts/generator.yaml b/submissions/devoteam/backend/prompts/generator.yaml new file mode 100644 index 00000000..0af106c3 --- /dev/null +++ b/submissions/devoteam/backend/prompts/generator.yaml @@ -0,0 +1,5 @@ +template: | + Rédige une {{ doc_type }} destinée au {{ ministry }}. + Le contenu suivant doit être transformé en texte administratif structuré et professionnel : + "{{ text }}" + Le ton doit être respectueux et formel. \ No newline at end of file diff --git a/submissions/devoteam/backend/prompts/router.yaml b/submissions/devoteam/backend/prompts/router.yaml new file mode 100644 index 00000000..0a72ece3 --- /dev/null +++ b/submissions/devoteam/backend/prompts/router.yaml @@ -0,0 +1,32 @@ +template: | + Tu es un assistant administratif. + À partir de ce texte : + "{{ text }}" + Choisis parmi les ministères suivants : {{ ministries|join(", ") }}. + Réponds seulement par le nom exact du ministère. + +ministries: + - Ministère de la Santé + - Ministère de l’Éducation nationale et de la Jeunesse + - Ministère de l’Intérieur + - Ministère des Finances + - Ministère de la Justice + - Ministère de l’Environnement + - Ministère du Travail, de la Santé et des Solidarités + + - Ministère de l’Europe et des Affaires étrangères + - Ministère des Armées + - Ministère de l’Économie, des Finances et de la Souveraineté industrielle et numérique + - Ministère de l’Enseignement supérieur et de la Recherche + - Ministère des Sports et des Jeux Olympiques et Paralympiques + + + + - Ministère de la Transition écologique et de la Cohésion des territoires + - Ministère de l’Agriculture et de la Souveraineté alimentaire + - Ministère de la Culture + - Ministère de la Transformation et de la Fonction publiques + - Ministère de la Transition énergétique + - Ministère des Sports et des Jeux Olympiques et Paralympiques + - Ministère délégué chargé des Transports + - Ministère de l'Égalité entre les femmes et les hommes et de la Lutte contre les discriminations diff --git a/submissions/devoteam/backend/routes/__init__.py b/submissions/devoteam/backend/routes/__init__.py new file mode 100644 index 00000000..235455aa --- /dev/null +++ b/submissions/devoteam/backend/routes/__init__.py @@ -0,0 +1,5 @@ +from .router import bp as router_bp +from .classifier import bp as classifier_bp +from .generator import bp as generator_bp + +__all__ = ["router_bp", "classifier_bp", "generator_bp"] \ No newline at end of file diff --git a/submissions/devoteam/backend/routes/classifier.py b/submissions/devoteam/backend/routes/classifier.py new file mode 100644 index 00000000..ba95e17a --- /dev/null +++ b/submissions/devoteam/backend/routes/classifier.py @@ -0,0 +1,18 @@ +from flask import Blueprint, request, jsonify +from services.pipeline import detect_doc_type + +bp = Blueprint("classifier_bp", __name__, url_prefix="/api/classify") + +@bp.post("") # POST /api/classify +def classify_document(): + data = request.get_json(silent=True) + if not data: + return jsonify(error="JSON invalide ou mal formé"), 400 + + text = data.get("text", "") + ministry = data.get("ministry", "") + if not text or not ministry: + return jsonify(error="Les champs 'text' et 'ministry' sont requis"), 400 + + doc_type = detect_doc_type(text, ministry) + return jsonify(document_type=doc_type) diff --git a/submissions/devoteam/backend/routes/generator.py b/submissions/devoteam/backend/routes/generator.py new file mode 100644 index 00000000..063e9d37 --- /dev/null +++ b/submissions/devoteam/backend/routes/generator.py @@ -0,0 +1,19 @@ +from flask import Blueprint, request, jsonify +from services.pipeline import generate_document + +bp = Blueprint("generator_bp", __name__, url_prefix="/api/generate") + +@bp.post("") # POST /api/generate +def generate_doc_route(): + data = request.get_json(silent=True) + if not data: + return jsonify(error="JSON invalide ou mal formé"), 400 + + text = data.get("text", "") + ministry = data.get("ministry", "") + doc_type = data.get("document_type", "") + if not text or not ministry or not doc_type: + return jsonify(error="Les champs 'text', 'ministry' et 'document_type' sont requis"), 400 + + document = generate_document(text, ministry, doc_type) + return jsonify(document=document) diff --git a/submissions/devoteam/backend/routes/router.py b/submissions/devoteam/backend/routes/router.py new file mode 100644 index 00000000..9dda1c62 --- /dev/null +++ b/submissions/devoteam/backend/routes/router.py @@ -0,0 +1,15 @@ +from flask import Blueprint, request, jsonify +from services.pipeline import detect_ministry + +bp = Blueprint("router_bp", __name__, url_prefix="/api") + +@bp.post("/route") +def route_ministry(): + data = request.get_json(silent=True) + if not data or "text" not in data: + return jsonify(error="Le champ 'text' est requis"), 400 + text = data["text"] + if not text: + return jsonify(error="Le champ 'text' ne peut être vide"), 400 + ministry = detect_ministry(text) + return jsonify(ministry=ministry) diff --git a/submissions/devoteam/backend/services/__init__.py b/submissions/devoteam/backend/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/backend/services/llm_client.py b/submissions/devoteam/backend/services/llm_client.py new file mode 100644 index 00000000..2f02bb67 --- /dev/null +++ b/submissions/devoteam/backend/services/llm_client.py @@ -0,0 +1,23 @@ +from openai import OpenAI, OpenAIError +from config import Config + +class LLMClient: + def __init__(self): + if not Config.OPENAI_API_KEY: + raise RuntimeError("API key manquante") + self.client = OpenAI( + base_url=Config.OPENAI_BASE_URL, + api_key=Config.OPENAI_API_KEY + ) + + def chat(self, model, messages, temperature, max_tokens=512): + try: + res = self.client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens + ) + return res.choices[0].message.content.strip() + except OpenAIError as e: + raise RuntimeError(f"Erreur LLM ({model}) : {e}") diff --git a/submissions/devoteam/backend/services/pipeline.py b/submissions/devoteam/backend/services/pipeline.py new file mode 100644 index 00000000..b6beec3b --- /dev/null +++ b/submissions/devoteam/backend/services/pipeline.py @@ -0,0 +1,47 @@ +from pathlib import Path +from services.llm_client import LLMClient +from services.prompt_loader import PromptLoader +from config import Config + +BASE_DIR = Path(__file__).resolve().parent.parent +PROMPTS_DIR = BASE_DIR / "prompts" +llm = LLMClient() + +def detect_ministry(text: str) -> str: + yaml_path = str(PROMPTS_DIR / "router.yaml") + meta = PromptLoader.load_yaml(yaml_path) + prompt = PromptLoader.render(yaml_path, text=text, ministries=meta["ministries"]) + return llm.chat( + Config.ROUTER_MODEL, + [{"role": "user", "content": prompt}], + Config.TEMPERATURE_ROUTER + ) + +def detect_doc_type(text: str, ministry: str) -> str: + yaml_path = str(PROMPTS_DIR / "classifier.yaml") + meta = PromptLoader.load_yaml(yaml_path) + prompt = PromptLoader.render( + yaml_path, + ministry=ministry, + text=text, + doc_types=meta["doc_types"] + ) + return llm.chat( + Config.CLASSIFIER_MODEL, + [{"role": "user", "content": prompt}], + Config.TEMPERATURE_CLASSIF + ) + +def generate_document(text: str, ministry: str, doc_type: str) -> str: + yaml_path = str(PROMPTS_DIR / "generator.yaml") + prompt = PromptLoader.render( + yaml_path, + ministry=ministry, + doc_type=doc_type, + text=text + ) + return llm.chat( + Config.GENERATOR_MODEL, + [{"role": "user", "content": prompt}], + Config.TEMPERATURE_GEN + ) diff --git a/submissions/devoteam/backend/services/prompt_loader.py b/submissions/devoteam/backend/services/prompt_loader.py new file mode 100644 index 00000000..47c16f80 --- /dev/null +++ b/submissions/devoteam/backend/services/prompt_loader.py @@ -0,0 +1,16 @@ +import yaml +from jinja2 import Template + +class PromptLoader: + @staticmethod + def load_yaml(path: str) -> dict: + """Charge le YAML et renvoie un dict contenant 'template' et les métadonnées.""" + with open(path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + + @staticmethod + def render(path: str, **context) -> str: + """Rend le template Jinja à partir du YAML et du contexte passé.""" + data = PromptLoader.load_yaml(path) + template = Template(data["template"]) + return template.render(**context) diff --git a/submissions/devoteam/backend/utils.py b/submissions/devoteam/backend/utils.py new file mode 100644 index 00000000..3c5c4ecc --- /dev/null +++ b/submissions/devoteam/backend/utils.py @@ -0,0 +1,19 @@ +from openai import OpenAI +from config import Config +# ------------------------------------------------------------------ +# Fonction pour obtenir un client OpenAI +# ------------------------------------------------------------------ + +def get_client(): + return OpenAI( + base_url=Config.OPENAI_BASE_URL, + api_key=Config.API_KEY_NVIDIA + ) + + + + + + + + diff --git a/submissions/devoteam/docker-compose.yml b/submissions/devoteam/docker-compose.yml new file mode 100644 index 00000000..e38e0f1f --- /dev/null +++ b/submissions/devoteam/docker-compose.yml @@ -0,0 +1,10 @@ +services: + backend: + build: + context: ./ + dockerfile: Dockerfile + container_name: backend + ports: + - "5002:5002" + environment: + - .env diff --git a/submissions/devoteam/docs/.dockerignore b/submissions/devoteam/docs/.dockerignore new file mode 100644 index 00000000..fe9c3334 --- /dev/null +++ b/submissions/devoteam/docs/.dockerignore @@ -0,0 +1,36 @@ +# Python +__pycache__ +*.pyc +**/__pycache__ +**/*.pyc +venv +.venv + +# System-specific files +.DS_Store +**/.DS_Store + +# Docker +docker compose.* +env.d + +# Docs +docs +*.md +*.log + +# Development/test cache & configurations +data +.cache +.circleci +.git +.vscode +.iml +.idea +db.sqlite3 +.mypy_cache +.pylint.d +.pytest_cache + +# Frontend +node_modules diff --git a/submissions/devoteam/docs/.gitlint b/submissions/devoteam/docs/.gitlint new file mode 100644 index 00000000..f7373b6a --- /dev/null +++ b/submissions/devoteam/docs/.gitlint @@ -0,0 +1,78 @@ +# All these sections are optional, edit this file as you like. +[general] +# Ignore certain rules, you can reference them by their id or by their full name +# ignore=title-trailing-punctuation, T3 + +# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this +# verbosity = 2 + +# By default gitlint will ignore merge commits. Set to 'false' to disable. +# ignore-merge-commits=true + +# By default gitlint will ignore fixup commits. Set to 'false' to disable. +# ignore-fixup-commits=true + +# By default gitlint will ignore squash commits. Set to 'false' to disable. +# ignore-squash-commits=true + +# Enable debug mode (prints more output). Disabled by default. +# debug=true + +# Set the extra-path where gitlint will search for user defined rules +# See http://jorisroovers.github.io/gitlint/user_defined_rules for details +extra-path=gitlint/ + +# [title-max-length] +# line-length=80 + +[title-must-not-contain-word] +# Comma-separated list of words that should not occur in the title. Matching is case +# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING" +# will not cause a violation, but "WIP: my title" will. +words=wip + +#[title-match-regex] +# python like regex (https://docs.python.org/2/library/re.html) that the +# commit-msg title must be matched to. +# Note that the regex can contradict with other rules if not used correctly +# (e.g. title-must-not-contain-word). +#regex= + +# [B1] +# B1 = body-max-line-length +# line-length=120 +# [body-min-length] +# min-length=5 + +# [body-is-missing] +# Whether to ignore this rule on merge commits (which typically only have a title) +# default = True +# ignore-merge-commits=false + +# [body-changed-file-mention] +# List of files that need to be explicitly mentioned in the body when they are changed +# This is useful for when developers often erroneously edit certain files or git submodules. +# By specifying this rule, developers can only change the file when they explicitly reference +# it in the commit message. +# files=gitlint/rules.py,README.md + +# [author-valid-email] +# python like regex (https://docs.python.org/2/library/re.html) that the +# commit author email address should be matched to +# For example, use the following regex if you only want to allow email addresses from foo.com +# regex=[^@]+@foo.com + +[ignore-by-title] +# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper) +# upgrade dependencies +regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$ +ignore=B6,UC1 + +# [ignore-by-body] +# Ignore certain rules for commits of which the body has a line that matches a regex +# E.g. Match bodies that have a line that that contain "release" +# regex=(.*)release(.*) +# +# Ignore certain rules, you can reference them by their id or by their full name +# Use 'all' to ignore all rules +# ignore=T1,body-min-length diff --git a/submissions/devoteam/docs/.gitmodules b/submissions/devoteam/docs/.gitmodules new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/.sops.yaml b/submissions/devoteam/docs/.sops.yaml new file mode 100644 index 00000000..42bcfade --- /dev/null +++ b/submissions/devoteam/docs/.sops.yaml @@ -0,0 +1,10 @@ +creation_rules: + - path_regex: ./* + key_groups: + - age: + - age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques + - age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo + - age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric + - age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud + - age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud + diff --git a/submissions/devoteam/docs/CHANGELOG.md b/submissions/devoteam/docs/CHANGELOG.md new file mode 100644 index 00000000..c831e4ee --- /dev/null +++ b/submissions/devoteam/docs/CHANGELOG.md @@ -0,0 +1,622 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.3.0] - 2025-05-06 + +### Added + +- ✨(backend) add endpoint checking media status #984 +- ✨(backend) allow setting session cookie age via env var #977 +- ✨(backend) allow theme customnization using a configuration file #948 +- ✨(frontend) Add a custom callout block to the editor #892 +- 🚩(frontend) version MIT only #911 +- ✨(backend) integrate maleware_detection from django-lasuite #936 +- 🏗️(frontend) Footer configurable #959 +- 🩺(CI) add lint spell mistakes #954 +- ✨(frontend) create generic theme #792 +- 🛂(frontend) block edition to not connected users #945 +- 🚸(frontend) Let loader during upload analyze #984 +- 🚩(frontend) feature flag on blocking edition #997 + +### Changed + +- 📝(frontend) Update documentation #949 +- ✅(frontend) Improve tests coverage #949 +- ⬆️(docker) upgrade backend image to python 3.13 #973 +- ⬆️(docker) upgrade node images to alpine 3.21 #973 + +### Fixed +- 🐛(y-provider) increase JSON size limits for transcription conversion #989 + +### Removed + +- 🔥(back) remove footer endpoint #948 + + +## [3.2.1] - 2025-05-06 + +## Fixed + +- 🐛(frontend) fix list copy paste #943 +- 📝(doc) update contributing policy (commit signatures are now mandatory) #895 + + +## [3.2.0] - 2025-05-05 + +## Added + +- 🚸(backend) make document search on title accent-insensitive #874 +- 🚩 add homepage feature flag #861 +- 📝(doc) update contributing policy (commit signatures are now mandatory) #895 +- ✨(settings) Allow configuring PKCE for the SSO #886 +- 🌐(i18n) activate chinese and spanish languages #884 +- 🔧(backend) allow overwriting the data directory #893 +- ➕(backend) add `django-lasuite` dependency #839 +- ✨(frontend) advanced table features #908 + +## Changed + +- ⚡️(frontend) reduce unblocking time for config #867 +- ♻️(frontend) bind UI with ability access #900 +- ♻️(frontend) use built-in Quote block #908 + +## Fixed + +- 🐛(nginx) fix 404 when accessing a doc #866 +- 🔒️(drf) disable browsable HTML API renderer #919 +- 🔒(frontend) enhance file download security #889 +- 🐛(backend) race condition create doc #633 +- 🐛(frontend) fix breaklines in custom blocks #908 + +## [3.1.0] - 2025-04-07 + +## Added + +- 🚩(backend) add feature flag for the footer #841 +- 🔧(backend) add view to manage footer json #841 +- ✨(frontend) add custom css style #771 +- 🚩(frontend) conditionally render AI button only when feature is enabled #814 + +## Changed + +- 🚨(frontend) block button when creating doc #749 + +## Fixed + +- 🐛(back) validate document content in serializer #822 +- 🐛(frontend) fix selection click past end of content #840 + +## [3.0.0] - 2025-03-28 + +## Added + +- 📄(legal) Require contributors to sign a DCO #779 + +## Changed + +- ♻️(frontend) Integrate UI kit #783 +- 🏗️(y-provider) manage auth in y-provider app #804 + +## Fixed + +- 🐛(backend) compute ancestor_links in get_abilities if needed #725 +- 🔒️(back) restrict access to document accesses #801 + + +## [2.6.0] - 2025-03-21 + +## Added + +- 📝(doc) add publiccode.yml #770 + +## Changed + +- 🚸(frontend) ctrl+k modal not when editor is focused #712 + +## Fixed + +- 🐛(back) allow only images to be used with the cors-proxy #781 +- 🐛(backend) stop returning inactive users on the list endpoint #636 +- 🔒️(backend) require at least 5 characters to search for users #636 +- 🔒️(back) throttle user list endpoint #636 +- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636 + + +## [2.5.0] - 2025-03-18 + +## Added + +- 📝(doc) Added GNU Make link to README #750 +- ✨(frontend) add pinning on doc detail #711 +- 🚩(frontend) feature flag analytic on copy as html #649 +- ✨(frontend) Custom block divider with export #698 +- 🌐(i18n) activate dutch language #742 +- ✨(frontend) add Beautify action to AI transform #478 +- ✨(frontend) add Emojify action to AI transform #478 + +## Changed + +- 🧑‍💻(frontend) change literal section open source #702 +- ♻️(frontend) replace cors proxy for export #695 +- 🚨(gitlint) Allow uppercase in commit messages #756 +- ♻️(frontend) Improve AI translations #478 + +## Fixed + +- 🐛(frontend) SVG export #706 +- 🐛(frontend) remove scroll listener table content #688 +- 🔒️(back) restrict access to favorite_list endpoint #690 +- 🐛(backend) refactor to fix filtering on children + and descendants views #695 +- 🐛(action) fix notify-argocd workflow #713 +- 🚨(helm) fix helmfile lint #736 +- 🚚(frontend) redirect to 401 page when 401 error #759 + + +## [2.4.0] - 2025-03-06 + +## Added + +- ✨(frontend) synchronize language-choice #401 + +## Changed + +- Use sentry tags instead of extra scope + +## Fixed + +- 🐛(frontend) fix collaboration error #684 + + +## [2.3.0] - 2025-03-03 + +## Added + +- ✨(backend) limit link reach/role select options depending on ancestors #645 +- ✨(backend) add new "descendants" action to document API endpoint #645 +- ✨(backend) new "tree" action on document detail endpoint #645 +- ✨(backend) allow forcing page size within limits #645 +- 💄(frontend) add error pages #643 +- 🔒️ Manage unsafe attachments #663 +- ✨(frontend) Custom block quote with export #646 +- ✨(frontend) add open source section homepage #666 +- ✨(frontend) synchronize language-choice #401 + +## Changed + +- 🛂(frontend) Restore version visibility #629 +- 📝(doc) minor README.md formatting and wording enhancements +- ♻️Stop setting a default title on doc creation #634 +- ♻️(frontend) misc ui improvements #644 + +## Fixed + +- 🐛(backend) allow any type of extensions for media download #671 +- ♻️(frontend) improve table pdf rendering +- 🐛(email) invitation emails in receivers language + +## [2.2.0] - 2025-02-10 + +## Added + +- 📝(doc) Add security.md and codeofconduct.md #604 +- ✨(frontend) add home page #608 +- ✨(frontend) cursor display on activity #609 +- ✨(frontend) Add export page break #623 + +## Changed + +- 🔧(backend) make AI feature reach configurable #628 + +## Fixed + +- 🌐(CI) Fix email partially translated #616 +- 🐛(frontend) fix cursor breakline #609 +- 🐛(frontend) fix style pdf export #609 + +## [2.1.0] - 2025-01-29 + +## Added + +- ✨(backend) add duplicate action to the document API endpoint +- ⚗️(backend) add util to extract text from base64 yjs document +- ✨(backend) add soft delete and restore API endpoints to documents #516 +- ✨(backend) allow organizing documents in a tree structure #516 +- ✨(backend) add "excerpt" field to document list serializer #516 +- ✨(backend) add github actions to manage Crowdin workflow #559 & #563 +- 📈Integrate Posthog #540 +- 🏷️(backend) add content-type to uploaded files #552 +- ✨(frontend) export pdf docx front side #537 + +## Changed + +- 💄(frontend) add abilities on doc row #581 +- 💄(frontend) improve DocsGridItem responsive padding #582 +- 🔧(backend) Bump maximum page size to 200 #516 +- 📝(doc) Improve Read me #558 + +## Fixed + +- 🐛Fix invitations #575 + +## Removed + +- 🔥(backend) remove "content" field from list serializer # 516 + +## [2.0.1] - 2025-01-17 + +## Fixed + +-🐛(frontend) share modal is shown when you don't have the abilities #557 +-🐛(frontend) title copy break app #564 + +## [2.0.0] - 2025-01-13 + +## Added + +- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531 +- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519 +- 💄(frontend) Add left panel #420 +- 💄(frontend) add filtering to left panel #475 +- ✨(frontend) new share modal ui #489 +- ✨(frontend) add favorite feature #515 +- 📝(documentation) Documentation about self-hosted installation #530 +- ✨(helm) helm versioning #530 + +## Changed + +- 🏗️(yjs-server) organize yjs server #528 +- ♻️(frontend) better separation collaboration process #528 +- 💄(frontend) updating the header and leftpanel for responsive #421 +- 💄(frontend) update DocsGrid component #431 +- 💄(frontend) update DocsGridOptions component #432 +- 💄(frontend) update DocHeader ui #448 +- 💄(frontend) update doc versioning ui #463 +- 💄(frontend) update doc summary ui #473 +- 📝(doc) update readme.md to match V2 changes #558 & #572 + +## Fixed + +- 🐛(backend) fix create document via s2s if sub unknown but email found #543 +- 🐛(frontend) hide search and create doc button if not authenticated #555 +- 🐛(backend) race condition creation issue #556 + +## [1.10.0] - 2024-12-17 + +## Added + +- ✨(backend) add server-to-server API endpoint to create documents #467 +- ✨(email) white brand email #412 +- ✨(y-provider) create a markdown converter endpoint #488 + +## Changed + +- ⚡️(docker) improve y-provider image #422 + +## Fixed + +- ⚡️(e2e) reduce flakiness on e2e tests #511 + +## Fixed + +- 🐛(frontend) update doc editor height #481 +- 💄(frontend) add doc search #485 + +## [1.9.0] - 2024-12-11 + +## Added + +- ✨(backend) annotate number of accesses on documents in list view #429 +- ✨(backend) allow users to mark/unmark documents as favorite #429 + +## Changed + +- 🔒️(collaboration) increase collaboration access security #472 +- 🔨(frontend) encapsulated title to its own component #474 +- ⚡️(backend) optimize number of queries on document list view #429 +- ♻️(frontend) stop to use provider with version #480 +- 🚚(collaboration) change the websocket key name #480 + +## Fixed + +- 🐛(frontend) fix initial content with collaboration #484 +- 🐛(frontend) Fix hidden menu on Firefox #468 +- 🐛(backend) fix sanitize problem IA #490 + +## [1.8.2] - 2024-11-28 + +## Changed + +- ♻️(SW) change strategy html caching #460 + +## [1.8.1] - 2024-11-27 + +## Fixed + +- 🐛(frontend) link not clickable and flickering firefox #457 + +## [1.8.0] - 2024-11-25 + +## Added + +- 🌐(backend) add German translation #259 +- 🌐(frontend) add German translation #255 +- ✨(frontend) add a broadcast store #387 +- ✨(backend) whitelist pod's IP address #443 +- ✨(backend) config endpoint #425 +- ✨(frontend) config endpoint #424 +- ✨(frontend) add sentry #424 +- ✨(frontend) add crisp chatbot #450 + +## Changed + +- 🚸(backend) improve users similarity search and sort results #391 +- ♻️(frontend) simplify stores #402 +- ✨(frontend) update $css Box props type to add styled components RuleSet #423 +- ✅(CI) trivy continue on error #453 + +## Fixed + +- 🔧(backend) fix logging for docker and make it configurable by envar #427 +- 🦺(backend) add comma to sub regex #408 +- 🐛(editor) collaborative user tag hidden when read only #385 +- 🐛(frontend) users have view access when revoked #387 +- 🐛(frontend) fix placeholder editable when double clicks #454 + +## [1.7.0] - 2024-10-24 + +## Added + +- 📝Contributing.md #352 +- 🌐(frontend) add localization to editor #368 +- ✨Public and restricted doc editable #357 +- ✨(frontend) Add full name if available #380 +- ✨(backend) Add view accesses ability #376 + +## Changed + +- ♻️(frontend) list accesses if user has abilities #376 +- ♻️(frontend) avoid documents indexing in search engine #372 +- 👔(backend) doc restricted by default #388 + +## Fixed + +- 🐛(backend) require right to manage document accesses to see invitations #369 +- 🐛(i18n) same frontend and backend language using shared cookies #365 +- 🐛(frontend) add default toolbar buttons #355 +- 🐛(frontend) throttle error correctly display #378 + +## Removed + +- 🔥(helm) remove infra related codes #366 + +## [1.6.0] - 2024-10-17 + +## Added + +- ✨AI to doc editor #250 +- ✨(backend) allow uploading more types of attachments #309 +- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #318 + +## Changed + +- ♻️(frontend) more multi theme friendly #325 +- ♻️ Bootstrap frontend #257 +- ♻️ Add username in email #314 + +## Fixed + +- 🛂(backend) do not duplicate user when disabled +- 🐛(frontend) invalidate queries after removing user #336 +- 🐛(backend) Fix dysfunctional permissions on document create #329 +- 🐛(backend) fix nginx docker container #340 +- 🐛(frontend) fix copy paste firefox #353 + +## [1.5.1] - 2024-10-10 + +## Fixed + +- 🐛(db) fix users duplicate #316 + +## [1.5.0] - 2024-10-09 + +## Added + +- ✨(backend) add name fields to the user synchronized with OIDC #301 +- ✨(ci) add security scan #291 +- ♻️(frontend) Add versions #277 +- ✨(frontend) one-click document creation #275 +- ✨(frontend) edit title inline #275 +- 📱(frontend) mobile responsive #304 +- 🌐(frontend) Update translation #308 + +## Changed + +- 💄(frontend) error alert closeable on editor #284 +- ♻️(backend) Change email content #283 +- 🛂(frontend) viewers and editors can access share modal #302 +- ♻️(frontend) remove footer on doc editor #313 + +## Fixed + +- 🛂(frontend) match email if no existing user matches the sub +- 🐛(backend) gitlab oicd userinfo endpoint #232 +- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292 +- ♻️(backend) getting list of document versions available for a user #258 +- 🔧(backend) fix configuration to avoid different ssl warning #297 +- 🐛(frontend) fix editor break line not working #302 + +## [1.4.0] - 2024-09-17 + +## Added + +- ✨Add link public/authenticated/restricted access with read/editor roles #234 +- ✨(frontend) add copy link button #235 +- 🛂(frontend) access public docs without being logged #235 + +## Changed + +- ♻️(backend) Allow null titles on documents for easier creation #234 +- 🛂(backend) stop to list public doc to everyone #234 +- 🚚(frontend) change visibility in share modal #235 +- ⚡️(frontend) Improve summary #244 + +## Fixed + +- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234 +- 🐛 Rebuild frontend dev container from makefile #248 + +## [1.3.0] - 2024-09-05 + +## Added + +- ✨Add image attachments with access control +- ✨(frontend) Upload image to a document #211 +- ✨(frontend) Summary #223 +- ✨(frontend) update meta title for docs page #231 + +## Changed + +- 💄(frontend) code background darkened on editor #214 +- 🔥(frontend) hide markdown button if not text #213 + +## Fixed + +- 🐛 Fix emoticon in pdf export #225 +- 🐛 Fix collaboration on document #226 +- 🐛 (docker) Fix compatibility with mac #230 + +## Removed + +- 🔥(frontend) remove saving modal #213 + +## [1.2.1] - 2024-08-23 + +## Changed + +- ♻️ Change ordering docs datagrid #195 +- 🔥(helm) use scaleway email #194 + +## [1.2.0] - 2024-08-22 + +## Added + +- 🎨(frontend) better conversion editor to pdf #151 +- ✨Export docx (word) #161 +- 🌐Internationalize invitation email #167 +- ✨(frontend) White branding #164 +- ✨Email invitation when add user to doc #171 +- ✨Invitation management #174 + +## Fixed + +- 🐛(y-webrtc) fix prob connection #147 +- ⚡️(frontend) improve select share stability #159 +- 🐛(backend) enable SSL when sending email #165 + +## Changed + +- 🎨(frontend) stop limit layout height to screen size #158 +- ⚡️(CI) only e2e chrome mandatory #177 + +## Removed + +- 🔥(helm) remove htaccess #181 + +## [1.1.0] - 2024-07-15 + +## Added + +- 🤡(demo) generate dummy documents on dev users #120 +- ✨(frontend) create side modal component #134 +- ✨(frontend) Doc grid actions (update / delete) #136 +- ✨(frontend) Doc editor header information #137 + +## Changed + +- ♻️(frontend) replace docs panel with docs grid #120 +- ♻️(frontend) create a doc from a modal #132 +- ♻️(frontend) manage members from the share modal #140 + +## [1.0.0] - 2024-07-02 + +## Added + +- 🛂(frontend) Manage the document's right (#75) +- ✨(frontend) Update document (#68) +- ✨(frontend) Remove document (#68) +- 🐳(docker) dockerize dev frontend (#63) +- 👔(backend) list users with email filtering (#79) +- ✨(frontend) add user to a document (#52) +- ✨(frontend) invite user to a document (#52) +- 🛂(frontend) manage members (update role / list / remove) (#81) +- ✨(frontend) offline mode (#88) +- 🌐(frontend) translate cgu (#83) +- ✨(service-worker) offline doc management (#94) +- ⚗️(frontend) Add beta tag on logo (#121) + +## Changed + +- ♻️(frontend) Change site from Impress to Docs (#76) +- ✨(frontend) Generate PDF from a modal (#68) +- 🔧(helm) sticky session by request_uri for signaling server (#78) +- ♻️(frontend) change logo (#84) +- ♻️(frontend) pdf has title doc (#84) +- ⚡️(e2e) unique login between tests (#80) +- ⚡️(CI) improve e2e job (#86) +- ♻️(frontend) improve the error and message info ui (#93) +- ✏️(frontend) change all occurrences of pad to doc (#99) + +## Fixed + +- 🐛(frontend) Fix the break line when generate PDF (#84) + +## Delete + +- 💚(CI) Remove trigger workflow on push tags on CI (#68) +- 🔥(frontend) Remove coming soon page (#121) + +## [0.1.0] - 2024-05-24 + +## Added + +- ✨(frontend) Coming Soon page (#67) +- 🚀 Impress, project to manage your documents easily and collaboratively. + +[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.3.0...main +[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0 +[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1 +[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0 +[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0 +[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0 +[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0 +[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0 +[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0 +[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0 +[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0 +[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0 +[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1 +[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0 +[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0 +[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0 +[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2 +[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1 +[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0 +[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0 +[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0 +[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1 +[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0 +[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0 +[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0 +[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1 +[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0 +[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0 +[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0 +[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0 diff --git a/submissions/devoteam/docs/CODE_OF_CONDUCT.md b/submissions/devoteam/docs/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..5407a0b4 --- /dev/null +++ b/submissions/devoteam/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Contributor Covenant Code of Conduct +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +- Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. +- Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +- This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at docs@numerique.gouv.fr. + +- All complaints will be reviewed and investigated promptly and fairly. + +- All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of the following Code of Conduct + +## Code of Conduct: + +### 1. Correction + +Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +Community Impact: A violation through a single incident or series of actions. + +Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +Community Impact: A serious violation of community standards, including sustained inappropriate behavior. + +Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +Consequence: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by Mozilla's [code of conduct enforcement ladder](https://github.com/mozilla/inclusion/blob/master/code-of-conduct-enforcement/consequence-ladder.md). + +For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/submissions/devoteam/docs/CONTRIBUTING.md b/submissions/devoteam/docs/CONTRIBUTING.md new file mode 100644 index 00000000..0e91bacf --- /dev/null +++ b/submissions/devoteam/docs/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing to the Project + +Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀 + +To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions on how to run Docs locally. + +Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`. + +Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices. + +## Help us with translations + +You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs). +Your language is not there? Request it on our Crowdin page 😊 or ping us on [Matrix](https://matrix.to/#/#docs-official:matrix.org) and let us know if you can help with translations and/or proofreading. + +## Creating an Issue + +When creating an issue, please provide the following details: + +1. **Title**: A concise and descriptive title for the issue. +2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable. +3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem. +4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened. +5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation). + +## Selecting an issue + +We use a [GitHub Project](https://github.com/orgs/numerique-gouv/projects/13) in order to prioritize our workload. + +Please check in priority the issues that are in the **todo** column and have a higher priority (P0 -> P2). + +## Commit Message Format + +All commit messages must adhere to the following format: + +`(type) title description` + +* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/). +* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc... +* **title**: A short, descriptive title for the change (*) +* **blank line after the commit title +* **description**: Include additional details on why you made the changes (**). + + (*) ⚠️ **Make sure you add no space between the emoji and the (type) but add a space after the closing parenthesis of the type and use no caps!** + (**) ⚠️ **Commit description message is mandatory and shouldn't be too long** + +### Example Commit Message + +``` +✨(frontend) add user authentication logic + +Implemented login and signup features, and integrated OAuth2 for social login. +``` + +## Changelog Update + +Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total. + +### Example Changelog Message +``` +## [Unreleased] + +## Added + +- ✨(frontend) add AI to the project #321 +``` + +## Pull Requests + +It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes. + +### Don't forget to: +- signoff your commits +- sign your commits with your key (SSH, GPG etc.) +- check your commits (see warnings above) +- check the linting: `make lint && make frontend-lint` +- check the tests: `make test` +- add a changelog entry + +Once all the required tests have passed, you can request a review from the project maintainers. + +## Code Style + +Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions. + +## Tests + +Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken. + +## Asking for Help + +If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist! + +Thank you for your contributions! 👍 + +## Contribute to BlockNote +We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs. +If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository. + +Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs. + +The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing). diff --git a/submissions/devoteam/docs/Dockerfile b/submissions/devoteam/docs/Dockerfile new file mode 100644 index 00000000..37c76034 --- /dev/null +++ b/submissions/devoteam/docs/Dockerfile @@ -0,0 +1,162 @@ +# Django impress + +# ---- base image to inherit from ---- +FROM python:3.13.3-alpine AS base + +# Upgrade pip to its latest release to speed up dependencies installation +RUN python -m pip install --upgrade pip setuptools + +# Upgrade system packages to install security updates +RUN apk update && \ + apk upgrade + +# ---- Back-end builder image ---- +FROM base AS back-builder + +WORKDIR /builder + +# Install Rust and Cargo using Alpine's package manager +RUN apk add --no-cache \ + build-base \ + libffi-dev \ + rust \ + cargo + +# Copy required python dependencies +COPY ./src/backend /builder + +RUN mkdir /install && \ + pip install --prefix=/install . + + +# ---- mails ---- +FROM node:24 AS mail-builder + +COPY ./src/mail /mail/app + +WORKDIR /mail/app + +RUN yarn install --frozen-lockfile && \ + yarn build + + +# ---- static link collector ---- +FROM base AS link-collector +ARG IMPRESS_STATIC_ROOT=/data/static + +# Install pango & rdfind +RUN apk add \ + pango \ + rdfind + +# Copy installed python dependencies +COPY --from=back-builder /install /usr/local + +# Copy impress application (see .dockerignore) +COPY ./src/backend /app/ + +WORKDIR /app + +# collectstatic +RUN DJANGO_CONFIGURATION=Build \ + python manage.py collectstatic --noinput + +# Replace duplicated file by a symlink to decrease the overall size of the +# final image +RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT} + +# ---- Core application image ---- +FROM base AS core + +ENV PYTHONUNBUFFERED=1 + +# Install required system libs +RUN apk add \ + cairo \ + file \ + font-noto \ + font-noto-emoji \ + gettext \ + gdk-pixbuf \ + libffi-dev \ + pango \ + shared-mime-info + +RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types + +# Copy entrypoint +COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint + +# Give the "root" group the same permissions as the "root" user on /etc/passwd +# to allow a user belonging to the root group to add new users; typically the +# docker user (see entrypoint). +RUN chmod g=u /etc/passwd + +# Copy installed python dependencies +COPY --from=back-builder /install /usr/local + +# Copy impress application (see .dockerignore) +COPY ./src/backend /app/ + +WORKDIR /app + +# Generate compiled translation messages +RUN DJANGO_CONFIGURATION=Build \ + python manage.py compilemessages + + +# We wrap commands run in this container by the following entrypoint that +# creates a user on-the-fly with the container user ID (see USER) and root group +# ID. +ENTRYPOINT [ "/usr/local/bin/entrypoint" ] + +# ---- Development image ---- +FROM core AS backend-development + +# Switch back to the root user to install development dependencies +USER root:root + +# Install psql +RUN apk add postgresql-client + +# Uninstall impress and re-install it in editable mode along with development +# dependencies +RUN pip uninstall -y impress +RUN pip install -e .[dev] + +# Restore the un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +# Target database host (e.g. database engine following docker compose services +# name) & port +ENV DB_HOST=postgresql \ + DB_PORT=5432 + +# Run django development server +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + +# ---- Production image ---- +FROM core AS backend-production + +# Remove apk cache, we don't need it anymore +RUN rm -rf /var/cache/apk/* + +ARG IMPRESS_STATIC_ROOT=/data/static + +# Gunicorn +RUN mkdir -p /usr/local/etc/gunicorn +COPY docker/files/usr/local/etc/gunicorn/impress.py /usr/local/etc/gunicorn/impress.py + +# Un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +# Copy statics +COPY --from=link-collector ${IMPRESS_STATIC_ROOT} ${IMPRESS_STATIC_ROOT} + +# Copy impress mails +COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail + +# The default command runs gunicorn WSGI server in impress's main module +CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/impress.py", "impress.wsgi:application"] diff --git a/submissions/devoteam/docs/LICENSE b/submissions/devoteam/docs/LICENSE new file mode 100644 index 00000000..d46fd062 --- /dev/null +++ b/submissions/devoteam/docs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/submissions/devoteam/docs/Makefile b/submissions/devoteam/docs/Makefile new file mode 100644 index 00000000..9942b6f0 --- /dev/null +++ b/submissions/devoteam/docs/Makefile @@ -0,0 +1,355 @@ +# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ +# +# This Makefile is only meant to be used for DEVELOPMENT purpose as we are +# changing the user id that will run in the container. +# +# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER... +# +# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ +# +# Note to developers: +# +# While editing this file, please respect the following statements: +# +# 1. Every variable should be defined in the ad hoc VARIABLES section with a +# relevant subsection +# 2. Every new rule should be defined in the ad hoc RULES section with a +# relevant subsection depending on the targeted service +# 3. Rules should be sorted alphabetically within their section +# 4. When a rule has multiple dependencies, you should: +# - duplicate the rule name to add the help string (if required) +# - write one dependency per line to increase readability and diffs +# 5. .PHONY rule statement should be written after the corresponding rule +# ============================================================================== +# VARIABLES + +BOLD := \033[1m +RESET := \033[0m +GREEN := \033[1;32m + + +# -- Database + +DB_HOST = postgresql +DB_PORT = 5432 + +# -- Docker +# Get the current user ID to use for docker run and docker exec commands +# Après +DOCKER_UID ?= 1000 +DOCKER_GID ?= 1000 +DOCKER_USER ?= 1000:1000 +COMPOSE = docker compose +COMPOSE_EXEC = $(COMPOSE) exec +COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev +COMPOSE_RUN = $(COMPOSE) run --rm +COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev +COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin + +# -- Backend +MANAGE = $(COMPOSE_RUN_APP) python manage.py +MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn + +# -- Frontend +PATH_FRONT = ./src/frontend +PATH_FRONT_IMPRESS = $(PATH_FRONT)/apps/impress + +# ============================================================================== +# RULES + +default: help + +data/media: + @mkdir -p data/media + +data/static: + @mkdir -p data/static + +# -- Project + +create-env-files: ## Copy the dist env files to env files +create-env-files: \ + env.d/development/common \ + env.d/development/crowdin \ + env.d/development/postgresql \ + env.d/development/kc_postgresql +.PHONY: create-env-files + +bootstrap: ## Prepare Docker images for the project +bootstrap: \ + data/media \ + data/static \ + create-env-files \ + build \ + migrate \ + demo \ + back-i18n-compile \ + mails-install \ + mails-build \ + run +.PHONY: bootstrap + +# -- Docker/compose +build: cache ?= --no-cache +build: ## build the project containers + @$(MAKE) build-backend cache=$(cache) + @$(MAKE) build-yjs-provider cache=$(cache) + @$(MAKE) build-frontend cache=$(cache) +.PHONY: build + +build-backend: cache ?= +build-backend: ## build the app-dev container + @$(COMPOSE) build app-dev $(cache) +.PHONY: build-backend + +build-yjs-provider: cache ?= +build-yjs-provider: ## build the y-provider container + @$(COMPOSE) build y-provider $(cache) +.PHONY: build-yjs-provider + +build-frontend: cache ?= +build-frontend: ## build the frontend container + @$(COMPOSE) build frontend $(cache) +.PHONY: build-frontend + +down: ## stop and remove containers, networks, images, and volumes + @$(COMPOSE) down +.PHONY: down + +logs: ## display app-dev logs (follow mode) + @$(COMPOSE) logs -f app-dev +.PHONY: logs + +run-backend: ## Start only the backend application and all needed services + @$(COMPOSE) up --force-recreate -d celery-dev + @$(COMPOSE) up --force-recreate -d y-provider + @$(COMPOSE) up --force-recreate -d nginx +.PHONY: run-backend + +run: ## start the wsgi (production) and development server +run: + @$(MAKE) run-backend + @$(COMPOSE) up --force-recreate -d frontend +.PHONY: run + +status: ## an alias for "docker compose ps" + @$(COMPOSE) ps +.PHONY: status + +stop: ## stop the development server using Docker + @$(COMPOSE) stop +.PHONY: stop + +# -- Backend + +demo: ## flush db then create a demo for load testing purpose + @$(MAKE) resetdb + @$(MANAGE) create_demo +.PHONY: demo + +# Nota bene: Black should come after isort just in case they don't agree... +lint: ## lint back-end python sources +lint: \ + lint-ruff-format \ + lint-ruff-check \ + lint-pylint +.PHONY: lint + +lint-ruff-format: ## format back-end python sources with ruff + @echo 'lint:ruff-format started…' + @$(COMPOSE_RUN_APP) ruff format . +.PHONY: lint-ruff-format + +lint-ruff-check: ## lint back-end python sources with ruff + @echo 'lint:ruff-check started…' + @$(COMPOSE_RUN_APP) ruff check . --fix +.PHONY: lint-ruff-check + +lint-pylint: ## lint back-end python sources with pylint only on changed files from main + @echo 'lint:pylint started…' + bin/pylint --diff-only=origin/main +.PHONY: lint-pylint + +test: ## run project tests + @$(MAKE) test-back-parallel +.PHONY: test + +test-back: ## run back-end tests + @args="$(filter-out $@,$(MAKECMDGOALS))" && \ + bin/pytest $${args:-${1}} +.PHONY: test-back + +test-back-parallel: ## run all back-end tests in parallel + @args="$(filter-out $@,$(MAKECMDGOALS))" && \ + bin/pytest -n auto $${args:-${1}} +.PHONY: test-back-parallel + +makemigrations: ## run django makemigrations for the impress project. + @echo "$(BOLD)Running makemigrations$(RESET)" + @$(COMPOSE) up -d postgresql + @$(MANAGE) makemigrations +.PHONY: makemigrations + +migrate: ## run django migrations for the impress project. + @echo "$(BOLD)Running migrations$(RESET)" + @$(COMPOSE) up -d postgresql + @$(MANAGE) migrate +.PHONY: migrate + +superuser: ## Create an admin superuser with password "admin" + @echo "$(BOLD)Creating a Django superuser$(RESET)" + @$(MANAGE) createsuperuser --email admin@example.com --password admin +.PHONY: superuser + +back-i18n-compile: ## compile the gettext files + @$(MANAGE) compilemessages --ignore="venv/**/*" +.PHONY: back-i18n-compile + +back-i18n-generate: ## create the .pot files used for i18n + @$(MANAGE) makemessages -a --keep-pot --all +.PHONY: back-i18n-generate + +shell: ## connect to database shell + @$(MANAGE) shell #_plus +.PHONY: dbshell + +# -- Database + +dbshell: ## connect to database shell + docker compose exec app-dev python manage.py dbshell +.PHONY: dbshell + +resetdb: FLUSH_ARGS ?= +resetdb: ## flush database and create a superuser "admin" + @echo "$(BOLD)Flush database$(RESET)" + @$(MANAGE) flush $(FLUSH_ARGS) + @${MAKE} superuser +.PHONY: resetdb + +env.d/development/common: + cp -n env.d/development/common.dist env.d/development/common + +env.d/development/postgresql: + cp -n env.d/development/postgresql.dist env.d/development/postgresql + +env.d/development/kc_postgresql: + cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql + +# -- Internationalization + +env.d/development/crowdin: + cp -n env.d/development/crowdin.dist env.d/development/crowdin + +crowdin-download: ## Download translated message from crowdin + @$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml +.PHONY: crowdin-download + +crowdin-download-sources: ## Download sources from Crowdin + @$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml +.PHONY: crowdin-download-sources + +crowdin-upload: ## Upload source translations to crowdin + @$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml +.PHONY: crowdin-upload + +i18n-compile: ## compile all translations +i18n-compile: \ + back-i18n-compile \ + frontend-i18n-compile +.PHONY: i18n-compile + +i18n-generate: ## create the .pot files and extract frontend messages +i18n-generate: \ + back-i18n-generate \ + frontend-i18n-generate +.PHONY: i18n-generate + +i18n-download-and-compile: ## download all translated messages and compile them to be used by all applications +i18n-download-and-compile: \ + crowdin-download \ + i18n-compile +.PHONY: i18n-download-and-compile + +i18n-generate-and-upload: ## generate source translations for all applications and upload them to Crowdin +i18n-generate-and-upload: \ + i18n-generate \ + crowdin-upload +.PHONY: i18n-generate-and-upload + + +# -- Mail generator + +mails-build: ## Convert mjml files to html and text + @$(MAIL_YARN) build +.PHONY: mails-build + +mails-build-html-to-plain-text: ## Convert html files to text + @$(MAIL_YARN) build-html-to-plain-text +.PHONY: mails-build-html-to-plain-text + +mails-build-mjml-to-html: ## Convert mjml files to html and text + @$(MAIL_YARN) build-mjml-to-html +.PHONY: mails-build-mjml-to-html + +mails-install: ## install the mail generator + @$(MAIL_YARN) install +.PHONY: mails-install + +# -- Misc +clean: ## restore repository state as it was freshly cloned + git clean -idx +.PHONY: clean + +help: + @echo "$(BOLD)impress Makefile" + @echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:" + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}' +.PHONY: help + +# Front +frontend-development-install: ## install the frontend locally + cd $(PATH_FRONT_IMPRESS) && yarn +.PHONY: frontend-development-install + +frontend-lint: ## run the frontend linter + cd $(PATH_FRONT) && yarn lint +.PHONY: frontend-lint + +run-frontend-development: ## Run the frontend in development mode + @$(COMPOSE) stop frontend + cd $(PATH_FRONT_IMPRESS) && yarn dev +.PHONY: run-frontend-development + +frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin + cd $(PATH_FRONT) && yarn i18n:extract +.PHONY: frontend-i18n-extract + +frontend-i18n-generate: ## Generate the frontend json files used for crowdin +frontend-i18n-generate: \ + crowdin-download-sources \ + frontend-i18n-extract +.PHONY: frontend-i18n-generate + +frontend-i18n-compile: ## Format the crowin json files used deploy to the apps + cd $(PATH_FRONT) && yarn i18n:deploy +.PHONY: frontend-i18n-compile + +# -- K8S +build-k8s-cluster: ## build the kubernetes cluster using kind + ./bin/start-kind.sh +.PHONY: build-k8s-cluster + +start-tilt: ## start the kubernetes cluster using kind + tilt up -f ./bin/Tiltfile +.PHONY: build-k8s-cluster + +bump-packages-version: VERSION_TYPE ?= minor +bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch" + cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE) + cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE) + cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE) + cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE) + cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE) + cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE) + cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE) +.PHONY: bump-packages-version diff --git a/submissions/devoteam/docs/README.md b/submissions/devoteam/docs/README.md new file mode 100644 index 00000000..1980c3dc --- /dev/null +++ b/submissions/devoteam/docs/README.md @@ -0,0 +1,213 @@ +

+ + Docs + +

+

+ + + + PRs Welcome + GitHub commit activity + GitHub closed issues + + GitHub closed issues + +

+

+ + Chat on Matrix + - + Documentation + - + Getting started + - + Reach out + +

+ +# La Suite Docs : Collaborative Text Editing +Docs, where your notes can become knowledge through live collaboration. + + + +## Why use Docs ❓ +Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing. + +It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence. + +### Write +* 😌 Get simple, accessible online editing for your team. +* 💅 Create clean documents with beautiful formatting options. +* 🖌️ Focus on your content using either the in-line editor, or [the Markdown syntax](https://www.markdownguide.org/basic-syntax/). +* 🧱 Quickly design your page thanks to the many block types, accessible from the `/` slash commands, as well as keyboard shortcuts. +* 🔌 Write offline! Your edits will be synced once you're back online. +* ✨ Save time thanks to our AI actions, such as rephrasing, summarizing, fixing typos, translating, etc. You can even turn your selected text into a prompt! + +### Work together +* 🤝 Enjoy live editing! See your team collaborate in real time. +* 🔒 Keep your information secure thanks to granular access control. Only share with the right people. +* 📑 Export your content in multiple formats (`.odt`, `.docx`, `.pdf`) with customizable templates. +* 📚 Turn your team's collaborative work into organized knowledge with Subpages. + +### Self-host +🚀 Docs is easy to install on your own servers + +Available methods: Helm chart, Nix package + +In the works: Docker Compose, YunoHost + +⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information. + +## Getting started 🔧 + +### Test it + +You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/) + +### Run Docs locally + +> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution. + +**Prerequisite** + +Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop, then type: + +```shellscript +$ docker -v + +Docker version 20.10.2, build 2291f61 + +$ docker compose version + +Docker Compose version v2.32.4 +``` + +> ⚠️ You may need to run the following commands with `sudo`, but this can be avoided by adding your user to the local `docker` group. + +**Project bootstrap** + +The easiest way to start working on the project is to use [GNU Make](https://www.gnu.org/software/make/): + +```shellscript +$ make bootstrap FLUSH_ARGS='--no-input' +``` + +This command builds the `app` container, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues. + +Your Docker services should now be up and running 🎉 + +You can access to the project by going to . + +You will be prompted to log in. The default credentials are: + +``` +username: impress +password: impress +``` + +📝 Note that if you need to run them afterwards, you can use the eponym Make rule: + +```shellscript +$ make run +``` + +⚠️ For the frontend developer, it is often better to run the frontend in development mode locally. + +To do so, install the frontend dependencies with the following command: + +```shellscript +$ make frontend-development-install +``` + +And run the frontend locally in development mode with the following command: + +```shellscript +$ make run-frontend-development +``` + +To start all the services, except the frontend container, you can use the following command: + +```shellscript +$ make run-backend +``` + +**Adding content** + +You can create a basic demo site by running this command: + +```shellscript +$ make demo +``` + +Finally, you can check all available Make rules using this command: + +```shellscript +$ make help +``` + +**Django admin** + +You can access the Django admin site at: + +. + +You first need to create a superuser account: + +```shellscript +$ make superuser +``` + +## Feedback 🙋‍♂️🙋‍♀️ + +We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org). + +## Roadmap + +Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11) + +## Licence 📝 + +This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)). + +While Docs is a public-driven initiative, our licence choice is an invitation for private sector actors to use, sell and contribute to the project. + +## Contributing 🙌 + +This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions. + +You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs). + +If you intend to make pull requests, see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines. + +## Directory structure: + +```markdown +docs +├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands. +├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project. +├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments. +├── docs - documentation for the project, including user guides, API documentation, and other helpful resources. +├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development. +├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages. +├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase. +└── src - main source code directory, containing the core application code, libraries, and modules of the project. +``` + +## Credits ❤️ + +### Stack + +Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/). We thank the contributors of all these projects for their awesome work! + +We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/). + + +### Gov ❤️ open source +Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)). + +We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs. + +

+ +

diff --git a/submissions/devoteam/docs/SECURITY.md b/submissions/devoteam/docs/SECURITY.md new file mode 100644 index 00000000..0ba904a8 --- /dev/null +++ b/submissions/devoteam/docs/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting a Vulnerability + +Security is very important to us. + +If you have any issue regarding security, please disclose the information responsibly submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr + +We appreciate your effort to make Docs more secure. + +## Vulnerability disclosure policy + +Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows: + +1. The Maintainers team will handle the fix as usual (Pull Request, +release). +2. In the release notes, we will include the identification numbers from the +GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities +and Exposures (CVE) identifier for the vulnerability. +3. Once this grace period has passed, we will publish the vulnerability. + +By adhering to this security policy, we aim to address security concerns +effectively and responsibly in our open source software project. \ No newline at end of file diff --git a/submissions/devoteam/docs/UPGRADE.md b/submissions/devoteam/docs/UPGRADE.md new file mode 100644 index 00000000..ea49216c --- /dev/null +++ b/submissions/devoteam/docs/UPGRADE.md @@ -0,0 +1,43 @@ +# Upgrade + +All instructions to upgrade this project from one release to the next will be +documented in this file. Upgrades must be run sequentially, meaning you should +not skip minor/major releases while upgrading (fix releases can be skipped). + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +For most upgrades, you just need to run the django migrations with +the following command inside your docker container: + +`python manage.py migrate` + +(Note : in your development environment, you can `make migrate`.) + +## [Unreleased] + +## [3.3.0] - 2025-05-22 + +⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information. + +The footer is now configurable from a customization file. To override the default one, you can +use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file. +The customization file must be a JSON file and must follow the rules described in the +[theming documentation](docs/theming.md). + +## [3.0.0] - 2025-03-28 + +We are not using the nginx auth request anymore to access the collaboration server (`yProvider`) +The authentication is now managed directly from the yProvider server. +You must remove the annotation `nginx.ingress.kubernetes.io/auth-url` from the `ingressCollaborationWS`. + +This means as well that the yProvider server must be able to access the Django server. +To do so, you must set the `COLLABORATION_BACKEND_BASE_URL` environment variable to the `yProvider` +service. + +## [2.2.0] - 2025-02-10 + +- AI features are now limited to users who are authenticated. Before this release, even anonymous + users who gained editor access on a document with link reach used to get AI feature. + IF you want anonymous users to keep access on AI features, you must now define the + `AI_ALLOW_REACH_FROM` setting to "public". diff --git a/submissions/devoteam/docs/bin/Tiltfile b/submissions/devoteam/docs/bin/Tiltfile new file mode 100644 index 00000000..5b3e72a7 --- /dev/null +++ b/submissions/devoteam/docs/bin/Tiltfile @@ -0,0 +1,71 @@ +load('ext://uibutton', 'cmd_button', 'bool_input', 'location') +load('ext://namespace', 'namespace_create', 'namespace_inject') +namespace_create('impress') + +docker_build( + 'localhost:5001/impress-backend:latest', + context='..', + dockerfile='../Dockerfile', + only=['./src/backend', './src/mail', './docker'], + target = 'backend-production', + live_update=[ + sync('../src/backend', '/app'), + run( + 'pip install -r /app/requirements.txt', + trigger=['./api/requirements.txt'] + ) + ] +) + +docker_build( + 'localhost:5001/impress-y-provider:latest', + context='..', + dockerfile='../src/frontend/servers/y-provider/Dockerfile', + only=['./src/frontend/', './docker/', './.dockerignore'], + target = 'y-provider', + live_update=[ + sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'), + ] +) + +docker_build( + 'localhost:5001/impress-frontend:latest', + context='..', + dockerfile='../src/frontend/Dockerfile', + only=['./src/frontend', './docker', './.dockerignore'], + target = 'impress', + live_update=[ + sync('../src/frontend', '/home/frontend'), + ] +) + +k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql']) +k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate']) +k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate']) +k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .')) + +migration = ''' +set -eu +# get k8s pod name from tilt resource name +POD_NAME="$(tilt get kubernetesdiscovery impress-backend -ojsonpath='{.status.pods[0].name}')" +kubectl -n impress exec "$POD_NAME" -- python manage.py makemigrations +''' +cmd_button('Make migration', + argv=['sh', '-c', migration], + resource='impress-backend', + icon_name='developer_board', + text='Run makemigration', +) + +pod_migrate = ''' +set -eu +# get k8s pod name from tilt resource name +POD_NAME="$(tilt get kubernetesdiscovery impress-backend -ojsonpath='{.status.pods[0].name}')" +kubectl -n impress exec "$POD_NAME" -- python manage.py migrate --no-input +''' +cmd_button('Migrate db', + argv=['sh', '-c', pod_migrate], + resource='impress-backend', + icon_name='developer_board', + text='Run database migration', +) diff --git a/submissions/devoteam/docs/bin/_config.sh b/submissions/devoteam/docs/bin/_config.sh new file mode 100644 index 00000000..854222a5 --- /dev/null +++ b/submissions/devoteam/docs/bin/_config.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +set -eo pipefail + +REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)" +UNSET_USER=0 + +TERRAFORM_DIRECTORY="./env.d/terraform" +COMPOSE_FILE="${REPO_DIR}/docker-compose.yml" + + +# _set_user: set (or unset) default user id used to run docker commands +# +# usage: _set_user +# +# You can override default user ID (the current host user ID), by defining the +# USER_ID environment variable. +# +# To avoid running docker commands with a custom user, please set the +# $UNSET_USER environment variable to 1. +function _set_user() { + + if [ $UNSET_USER -eq 1 ]; then + USER_ID="" + return + fi + + # USER_ID = USER_ID or `id -u` if USER_ID is not set + USER_ID=${USER_ID:-$(id -u)} + + echo "🙋(user) ID: ${USER_ID}" +} + +# docker_compose: wrap docker compose command +# +# usage: docker_compose [options] [ARGS...] +# +# options: docker compose command options +# ARGS : docker compose command arguments +function _docker_compose() { + + echo "🐳(compose) file: '${COMPOSE_FILE}'" + docker compose \ + -f "${COMPOSE_FILE}" \ + --project-directory "${REPO_DIR}" \ + "$@" +} + +# _dc_run: wrap docker compose run command +# +# usage: _dc_run [options] [ARGS...] +# +# options: docker compose run command options +# ARGS : docker compose run command arguments +function _dc_run() { + _set_user + + user_args="--user=$USER_ID" + if [ -z $USER_ID ]; then + user_args="" + fi + + _docker_compose run --rm $user_args "$@" +} + +# _dc_exec: wrap docker compose exec command +# +# usage: _dc_exec [options] [ARGS...] +# +# options: docker compose exec command options +# ARGS : docker compose exec command arguments +function _dc_exec() { + _set_user + + echo "🐳(compose) exec command: '\$@'" + + user_args="--user=$USER_ID" + if [ -z $USER_ID ]; then + user_args="" + fi + + _docker_compose exec $user_args "$@" +} + +# _django_manage: wrap django's manage.py command with docker compose +# +# usage : _django_manage [ARGS...] +# +# ARGS : django's manage.py command arguments +function _django_manage() { + _dc_run "app-dev" python manage.py "$@" +} + +# _set_openstack_project: select an OpenStack project from the openrc files defined in the +# terraform directory. +# +# usage: _set_openstack_project +# +# If necessary the script will prompt the user to choose a project from those available +function _set_openstack_project() { + + declare prompt + declare -a projects + declare -i default=1 + declare -i choice=0 + declare -i n_projects + + # List projects by looking in the "./env.d/terraform" directory + # and store them in an array + read -r -a projects <<< "$( + find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d | + sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' | + xargs + )" + nb_projects=${#projects[@]} + + if [[ ${nb_projects} -le 0 ]]; then + echo "There are no OpenStack projects defined..." >&2 + echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \ + "of your project and copy your \"openrc.sh\" file into it." >&2 + exit 10 + fi + + if [[ ${nb_projects} -gt 1 ]]; then + prompt="Select an OpenStack project to target:\\n" + for (( i=0; i&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})") + exit 11 + fi + + if [[ ${choice} -le 0 ]]; then + choice=${default} + fi + fi + + project=${projects[$((choice-1))]} + # Check that the openrc.sh file exists for this project + if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then + (>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.") + exit 12 + fi + + echo "${project}" +} diff --git a/submissions/devoteam/docs/bin/compose b/submissions/devoteam/docs/bin/compose new file mode 100644 index 00000000..1adb3d83 --- /dev/null +++ b/submissions/devoteam/docs/bin/compose @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +# shellcheck source=bin/_config.sh +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_docker_compose "$@" diff --git a/submissions/devoteam/docs/bin/install-hooks.sh b/submissions/devoteam/docs/bin/install-hooks.sh new file mode 100644 index 00000000..7d1c790c --- /dev/null +++ b/submissions/devoteam/docs/bin/install-hooks.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/" +PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit" + +cat <<'EOF' >$PRE_COMMIT_FILE +#!/bin/bash + +# directories containing potential secrets +DIRS="." + +bold=$(tput bold) +normal=$(tput sgr0) + +# allow to read user input, assigns stdin to keyboard +exec + sh -c " + /usr/bin/mc alias set impress http://minio:9000 impress password && \ + /usr/bin/mc mb impress/impress-media-storage && \ + /usr/bin/mc version enable impress/impress-media-storage && \ + exit 0;" + + app-dev: + build: + context: . + target: backend-development + args: + DOCKER_USER: ${DOCKER_USER:-1000} + user: ${DOCKER_USER:-1000} + image: impress:backend-development + environment: + - PYLINTHOME=/app/.pylint.d + - DJANGO_CONFIGURATION=Development + env_file: + - env.d/development/common + - env.d/development/postgresql + ports: + - "8071:8000" + volumes: + - ./src/backend:/app + - ./data/static:/data/static + depends_on: + postgresql: + condition: service_healthy + restart: true + mailcatcher: + condition: service_started + redis: + condition: service_started + createbuckets: + condition: service_started + + celery-dev: + user: ${DOCKER_USER:-1000} + image: impress:backend-development + command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"] + environment: + - DJANGO_CONFIGURATION=Development + env_file: + - env.d/development/common + - env.d/development/postgresql + volumes: + - ./src/backend:/app + - ./data/static:/data/static + depends_on: + - app-dev + + app: + build: + context: . + target: backend-production + args: + DOCKER_USER: ${DOCKER_USER:-1000} + user: ${DOCKER_USER:-1000} + image: impress:backend-production + environment: + - DJANGO_CONFIGURATION=Demo + env_file: + - env.d/development/common + - env.d/development/postgresql + depends_on: + postgresql: + condition: service_healthy + restart: true + redis: + condition: service_started + minio: + condition: service_started + + celery: + user: ${DOCKER_USER:-1000} + image: impress:backend-production + command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"] + environment: + - DJANGO_CONFIGURATION=Demo + env_file: + - env.d/development/common + - env.d/development/postgresql + depends_on: + - app + + nginx: + image: nginx:1.25 + ports: + - "8083:8083" + volumes: + - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro + depends_on: + app-dev: + condition: service_started + y-provider: + condition: service_started + keycloak: + condition: service_healthy + restart: true + + frontend: + user: "${DOCKER_USER:-1000}" + build: + context: . + dockerfile: ./src/frontend/Dockerfile + target: frontend-production + args: + API_ORIGIN: "http://localhost:8071" + PUBLISH_AS_MIT: "false" + SW_DEACTIVATED: "true" + image: impress:frontend-development + ports: + - "3000:3000" + + crowdin: + image: crowdin/cli:3.16.0 + volumes: + - ".:/app" + env_file: + - env.d/development/crowdin + user: "${DOCKER_USER:-1000}" + working_dir: /app + + node: + image: node:18 + user: "${DOCKER_USER:-1000}" + environment: + HOME: /tmp + volumes: + - ".:/app" + + y-provider: + user: ${DOCKER_USER:-1000} + build: + context: . + dockerfile: ./src/frontend/servers/y-provider/Dockerfile + target: y-provider + restart: unless-stopped + env_file: + - env.d/development/common + ports: + - "4444:4444" + + kc_postgresql: + image: postgres:14.3 + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 1s + timeout: 2s + retries: 300 + ports: + - "5433:5432" + env_file: + - env.d/development/kc_postgresql + + keycloak: + image: quay.io/keycloak/keycloak:20.0.1 + volumes: + - ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json + command: + - start-dev + - --features=preview + - --import-realm + - --proxy=edge + - --hostname-url=http://localhost:8083 + - --hostname-admin-url=http://localhost:8083/ + - --hostname-strict=false + - --hostname-strict-https=false + - --health-enabled=true + - --metrics-enabled=true + healthcheck: + test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"] + interval: 1s + timeout: 2s + retries: 300 + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_DB: postgres + KC_DB_URL_HOST: kc_postgresql + KC_DB_URL_DATABASE: keycloak + KC_DB_PASSWORD: pass + KC_DB_USERNAME: impress + KC_DB_SCHEMA: public + PROXY_ADDRESS_FORWARDING: 'true' + ports: + - "8080:8080" + depends_on: + kc_postgresql: + condition: service_healthy + restart: true diff --git a/submissions/devoteam/docs/docker/auth/realm.json b/submissions/devoteam/docs/docker/auth/realm.json new file mode 100644 index 00000000..cee70d21 --- /dev/null +++ b/submissions/devoteam/docs/docker/auth/realm.json @@ -0,0 +1,2281 @@ +{ + "id": "ccf4fd40-4286-474d-854a-4714282a8bec", + "realm": "impress", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "users": [ + { + "username": "impress", + "email": "impress@impress.world", + "firstName": "John", + "lastName": "Doe", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "impress" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user-e2e-chromium", + "email": "user@chromium.e2e", + "firstName": "E2E", + "lastName": "Chromium", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "password-e2e-chromium" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user-e2e-webkit", + "email": "user@webkit.e2e", + "firstName": "E2E", + "lastName": "Webkit", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "password-e2e-webkit" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user-e2e-firefox", + "email": "user@firefox.e2e", + "firstName": "E2E", + "lastName": "Firefox", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "password-e2e-firefox" + } + ], + "realmRoles": ["user"] + } + ], + "roles": { + "realm": [ + { + "id": "1f116065-05b6-4269-80a6-c7d904b584b7", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "ccf4fd40-4286-474d-854a-4714282a8bec", + "attributes": {} + }, + { + "id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99", + "name": "default-roles-impress", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": ["offline_access", "uma_authorization"], + "client": { + "account": ["view-profile", "manage-account"] + } + }, + "clientRole": false, + "containerId": "ccf4fd40-4286-474d-854a-4714282a8bec", + "attributes": {} + }, + { + "id": "8733db03-278a-45ad-a25e-c167fbd95b5a", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "ccf4fd40-4286-474d-854a-4714282a8bec", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "9dcc0883-e2e5-4671-9159-402bdbe73c57", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "ae911be0-ea2e-466d-93e0-f8e73fa8f444", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "e777d332-7205-4b76-8b21-9191a2e85a0d", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "b1a95608-d518-4ede-936e-525ab704d363", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "ac58976a-ae55-4d92-a864-b33e21b07c54", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "a149b28f-d252-4ceb-8ba9-8161603c4184", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "00a5b886-7ca4-4fba-90c6-a9071e697d86", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "b22d5cc1-879e-4405-8345-cc204fd0fec0", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "impersonation", + "view-authorization", + "manage-authorization", + "create-client", + "view-events", + "manage-identity-providers", + "manage-clients", + "view-identity-providers", + "query-users", + "manage-users", + "view-clients", + "view-users", + "manage-events", + "view-realm", + "query-realms", + "query-groups", + "manage-realm", + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "b3e9faf6-17bf-4f62-abd5-07837806a7e6", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "a8d85f42-023b-48dd-8f49-c9da2b5317ee", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "eb325a4d-db7a-4f6a-a88b-0ff8aa38b0a5", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "267bb612-62f4-4354-abb2-ac6a34bd854b", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "b575be2b-e250-4000-b75e-3038cda8c0dd", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "e19cd0bf-8da0-457d-b630-454c611bc1ba", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-users", "query-groups"] + } + }, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "c12145cc-cbdc-4ef3-9774-19b1852811ba", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "e7e15b84-4971-4c13-be93-315bb36d30e1", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "e03d2989-a620-4918-85ed-3eabd0373bb4", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "daf8d347-4b30-41d6-a431-7b3723dd8e6f", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "432cd3eb-4741-46ba-938a-94ff9dece315", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "2e713186-38da-44d7-a5a5-19d91ef2dfca", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "41dd8f26-46c2-471a-859e-01886f972ff9", + "attributes": {} + } + ], + "impress": [], + "account": [ + { + "id": "63b1a4e1-a594-4571-99c3-7c5c3efd61ce", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "36ef5fd6-1167-4ba0-9171-c8cb6cfe904b", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "f984654a-fca5-45d9-bb47-73009eb9bcf0", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "d54168c5-58a5-4f13-9fa8-6dbbee0e4b73", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "092b6808-1ee2-44be-9b5d-085ccd6862b4", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "ddd57af0-2a5e-4f9d-98e5-ec96c8d852ce", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "84c7324a-4724-41fe-8bd4-848ce5cebd5b", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "20d06f75-ea65-4b99-b9ef-2384ffd1de53", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99", + "name": "default-roles-impress", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "ccf4fd40-4286-474d-854a-4714282a8bec" + }, + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": ["totpAppGoogleName", "totpAppFreeOTPName"], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account", "view-groups"] + } + ] + }, + "clients": [ + { + "id": "06721011-1061-4ca7-944f-be2a20719e20", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/impress/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/impress/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "987e14a5-caed-40a6-8bac-8c429b74ca48", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/impress/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/impress/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "4f958126-eaa1-46d5-967a-3a3c2e2d11f7", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "92da37ad-e8a1-41f1-93c6-541dffa7d601", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "41dd8f26-46c2-471a-859e-01886f972ff9", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "869481d0-5774-4e64-bc30-fedc7c58958f", + "clientId": "impress", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "ThisIsAnExampleKeyForDevPurposeOnly", + "redirectUris": [ + "http://localhost:8070/*", + "http://localhost:8071/*", + "http://localhost:3200/*", + "http://localhost:8088/*", + "http://localhost:3000/*" + ], + "webOrigins": [ + "http://localhost:3200", + "http://localhost:8088", + "http://localhost:8070", + "http://localhost:3000" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "-1", + "client.secret.creation.time": "1707820779", + "user.info.response.signature.alg": "RS256", + "post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*", + "oauth2.device.authorization.grant.enabled": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "tls-client-certificate-bound-access-tokens": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "acr.loa.map": "{}", + "require.pushed.authorization.requests": "false", + "display.on.consent.screen": "false", + "client.session.idle.timeout": "-1", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "2a4e007a-2fc4-4f43-aace-b93aec9221b4", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/impress/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/admin/impress/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "4913be96-5827-46a4-9909-562c2dd5bef6", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "74aeb8e2-a1b6-4897-9eaf-d922becea170", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "994b8f5e-dfc1-4154-a936-347336e6422a", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "d853f97e-80f8-470e-8447-815b289d9ae3", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "26a9f3ef-cff0-4dee-9fe9-778cd1d2a771", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "af52ccc3-4ecb-49b4-9a67-5d4172f16070", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "efb82630-8835-4de0-944e-ac5ea51eca48", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "2256189a-7970-4244-b496-64cbba3ce582", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "6d7f8b9e-997e-40f8-bae5-83d2647fbeff", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "b83cebb6-f086-48e2-8e5a-9802736342f2", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "b99113c6-ccfb-43d4-acd1-09dd34cdf5bc", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "696211d7-c434-495f-b3a0-a1b88bebfd6e", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "16845bd9-5626-4484-b4c5-00af52d8ad8b", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "5828a7d9-cdc7-456b-a747-16bf83c2f57d", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "ce289e05-eca4-4323-b457-822d39cc6d49", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "abe63488-9a39-4e29-a0a8-824db0887b60", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "15690cfb-e14c-46e8-8494-22a0365a4b0c", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "03cf0e4c-c2a5-4203-88c4-5391d361ba15", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "23b1a1da-2ecc-4db7-8d33-4e9233a81e89", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "26a72777-56eb-4b46-acca-eca8168e29fc", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "4ae1896b-ea82-4604-8f0e-72133fdee05c", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b", + "name": "first name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "first_name", + "jsonType.label": "String" + } + }, + { + "id": "6397c5e9-95ea-4c31-bd44-a8acf1d18472", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "7f741e96-41fe-4021-bbfd-506e7eb94e69", + "name": "last name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "last_name", + "jsonType.label": "String" + } + }, + { + "id": "5ca62964-2d04-4e8e-963d-e3b08cf32d7c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "954a5dff-cc19-4dde-b996-787f767db4cc", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "1eba19bf-6fa1-4608-ad2d-d4346580c93d", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "e7bdd267-fcce-451f-b3e1-a775cf611dd2", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "a9a8918c-af00-48a5-a8b3-a28a83653f71", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "cd725067-b6ba-42f1-a940-97a16a23cb85", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "a4e1812c-4093-4666-a6b3-03c5d9b5ca9f", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "d6690292-74d1-48ac-855d-2f0f3799829e", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "ce8f1215-0462-4e87-8a3b-18488aee0267", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "0ce95430-80aa-4dd6-994b-5a67302ba531", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "8da0d3b1-d609-417e-9adc-1de77549baf9", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "f89a9158-7c03-49b0-8a3c-d0b75e2ce1b4", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "fb109597-e31e-46d7-84c5-62e5fcf32ac8", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "qb109597-e31e-46d7-7844-62e5fcf32ac8", + "name": "email sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "sub", + "jsonType.label": "String" + } + }, + { + "id": "61c135e5-2447-494b-bc70-9612f383be27", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "74dffa9a-5d4f-4ce3-9708-885212f56861", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "48096073-ceae-4e68-a15b-f1aa390dcce5", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "51b0e87c-ee04-4664-a299-f8e49cb7a9ac", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "6379b091-2289-4fe7-894c-c03f1bd0e69b", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "97ae8320-a439-463b-817e-05bd4a6c39d1", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper" + ] + } + }, + { + "id": "49131ffc-4831-4e3e-a466-f9f08aa1bee0", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "e12647d2-e21f-49bc-a8c6-28154c5544d2", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper", + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "c9f00ef2-00d9-44bd-9b6c-3b3bf57e44ba", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "96260850-72a5-4b49-b96b-5a33d0b5337d", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "55d93b4d-fe05-46a1-a832-36f380aaddf7", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": ["100"] + } + }, + { + "id": "bee288b4-ecdf-4ec4-8c31-ee330f1e8f95", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": ["100"], + "algorithm": ["HS256"] + } + }, + { + "id": "2aa8f54d-8b4b-4eb7-a05b-89211f544358", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": ["100"], + "algorithm": ["RSA-OAEP"] + } + }, + { + "id": "23ad48f4-2275-4a0d-aa0d-1e0691f9c620", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": ["100"] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "0c349304-21fd-47ff-8dc6-46efb107b7e9", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "cf1ed416-7274-4804-88bf-4261b0bacdc6", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "basic-auth-otp", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d949f1f1-4622-49ec-b74a-4b8a58c653d2", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3deb6d9d-2064-410c-af99-b1601cd9b1c4", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "f777c4be-f7d1-453e-a9d7-a2a235b7975b", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "1bc12f49-e2ef-42bd-959a-0983e1cd4d65", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "324cdcf5-8f31-4768-9db9-63208f182b39", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "23d17138-8ebd-4195-91d3-614094f62070", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "61fec72a-bfd2-42e8-95c1-fa0b76c1cd2b", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "dc00b9a8-fc37-4591-a1ea-07c7f884d394", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "4f27245a-49b8-4870-a5e2-f0ea624a792c", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "5b2c66e1-7bbf-4707-9db8-244269b68164", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "4bcddec4-4260-4f4f-a757-3aff9b1d30f3", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "04a94e38-b7fb-48f6-8d63-5640f835c619", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "bfcf5112-96ac-485a-8663-b02ad41af919", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "e262d10d-ad0d-4d18-bc05-3a44f7d21736", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Authentication Options", + "userSetupAllowed": false + } + ] + }, + { + "id": "b671c4b3-22b6-4aac-a1d1-464a2101767c", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "f570e064-0e62-4eae-8087-8b06751b8f33", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "07124099-1d10-4148-ac06-4b0b700908da", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "0a5fa089-f987-4903-9170-36565edda152", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "d2818365-2189-4003-9817-0ad5368e37f3", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "72508559-0176-4eee-a77e-0795d652be12", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "UPDATE_EMAIL", + "name": "Update Email", + "providerId": "UPDATE_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "20.0.1", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/submissions/devoteam/docs/docker/files/etc/nginx/conf.d/default.conf b/submissions/devoteam/docs/docker/files/etc/nginx/conf.d/default.conf new file mode 100644 index 00000000..072fdc28 --- /dev/null +++ b/submissions/devoteam/docs/docker/files/etc/nginx/conf.d/default.conf @@ -0,0 +1,52 @@ + +server { + listen 8083; + server_name localhost; + charset utf-8; + + # Proxy auth for media + location /media/ { + # Auth request configuration + auth_request /media-auth; + auth_request_set $authHeader $upstream_http_authorization; + auth_request_set $authDate $upstream_http_x_amz_date; + auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256; + + # Pass specific headers from the auth response + proxy_set_header Authorization $authHeader; + proxy_set_header X-Amz-Date $authDate; + proxy_set_header X-Amz-Content-SHA256 $authContentSha256; + + # Get resource from Minio + proxy_pass http://minio:9000/impress-media-storage/; + proxy_set_header Host minio:9000; + + add_header Content-Security-Policy "default-src 'none'" always; + } + + location /media-auth { + proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Original-URL $request_uri; + + # Prevent the body from being passed + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + } + + location / { + proxy_pass http://keycloak:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # Increase proxy buffer size to allow keycloak to send large + # header responses when a user is created. + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } +} diff --git a/submissions/devoteam/docs/docker/files/usr/local/bin/entrypoint b/submissions/devoteam/docs/docker/files/usr/local/bin/entrypoint new file mode 100644 index 00000000..273a86ab --- /dev/null +++ b/submissions/devoteam/docs/docker/files/usr/local/bin/entrypoint @@ -0,0 +1,35 @@ +#!/bin/sh +# +# The container user (see USER in the Dockerfile) is an un-privileged user that +# does not exists and is not created during the build phase (see Dockerfile). +# Hence, we use this entrypoint to wrap commands that will be run in the +# container to create an entry for this user in the /etc/passwd file. +# +# The following environment variables may be passed to the container to +# customize running user account: +# +# * USER_NAME: container user name (default: default) +# * HOME : container user home directory (default: none) +# +# To pass environment variables, you can either use the -e option of the docker run command: +# +# docker run --rm -e USER_NAME=foo -e HOME='/home/foo' impress:latest python manage.py migrate +# +# or define new variables in an environment file to use with docker or docker compose: +# +# # env.d/production +# USER_NAME=foo +# HOME=/home/foo +# +# docker run --rm --env-file env.d/production impress:latest python manage.py migrate +# + +echo "🐳(entrypoint) creating user running in the container..." +if ! whoami > /dev/null 2>&1; then + if [ -w /etc/passwd ]; then + echo "${USER_NAME:-default}:x:$(id -u):$(id -g):${USER_NAME:-default} user:${HOME}:/sbin/nologin" >> /etc/passwd + fi +fi + +echo "🐳(entrypoint) running your command: ${*}" +exec "$@" diff --git a/submissions/devoteam/docs/docker/files/usr/local/etc/gunicorn/impress.py b/submissions/devoteam/docs/docker/files/usr/local/etc/gunicorn/impress.py new file mode 100644 index 00000000..b822b768 --- /dev/null +++ b/submissions/devoteam/docs/docker/files/usr/local/etc/gunicorn/impress.py @@ -0,0 +1,16 @@ +# Gunicorn-django settings +bind = ["0.0.0.0:8000"] +name = "impress" +python_path = "/app" + +# Run +graceful_timeout = 90 +timeout = 90 +workers = 3 + +# Logging +# Using '-' for the access log file makes gunicorn log accesses to stdout +accesslog = "-" +# Using '-' for the error log file makes gunicorn log errors to stderr +errorlog = "-" +loglevel = "info" diff --git a/submissions/devoteam/docs/docs/adr/ADR-0001-20250106-use-yjs-for-docs-editing.md b/submissions/devoteam/docs/docs/adr/ADR-0001-20250106-use-yjs-for-docs-editing.md new file mode 100644 index 00000000..486b8b0b --- /dev/null +++ b/submissions/devoteam/docs/docs/adr/ADR-0001-20250106-use-yjs-for-docs-editing.md @@ -0,0 +1,193 @@ +## Decision TLDR; + +We will use Yjs a CRDT-based library for the collaborative editing of the documents. + +## Status + +Accepted + +## Context + +We need to implement a collaborative editing feature for the documents that supports real-time collaboration, offline capabilities, and seamless integration with our Django backend. + +## Considered alternatives + +### ProseMirror + +A robust toolkit for building rich-text editors with collaboration capabilities. + +| Pros | Cons | +| --- | --- | +| Mature ecosystem | Complex integration with Django | +| Rich text editing features | Steeper learning curve | +| Used by major companies | More complex to implement offline support | +| Large community | | + +### ShareDB + +Real-time database backend based on Operational Transformation. + +| Pros | Cons | +| --- | --- | +| Battle-tested in production | Complex setup required | +| Strong consistency model | Requires specific backend architecture | +| Good documentation | Less flexible with different backends | +| | Higher latency compared to CRDTs | + +### Convergence + +Complete enterprise solution for real-time collaboration. + +| Pros | Cons | +| --- | --- | +| Full-featured solution | Commercial licensing | +| Built-in presence features | Less community support | +| Enterprise support | More expensive | +| Good offline support | Overkill for basic needs | + +### CRDT-based Solutions Comparison + +A CRDT-based library specifically designed for real-time collaboration. + +| Category | Pros | Cons | +|----------|------|------| +| Technical Implementation | • Native real-time collaboration
• No central conflict resolution needed
• Works well with Django backend
• Automatic state synchronization | • Learning curve for CRDT concepts
• More complex initial setup
• Additional metadata overhead | +| User Experience | • Instant local updates
• Works offline by default
• Low latency
• Smooth concurrent editing | • Eventual consistency might cause brief inconsistencies
• UI must handle temporary conflicts | +| Performance | • Excellent scaling with multiple users
• Reduced server load
• Efficient network usage
• Good memory optimization (especially Yjs) | • Slightly higher memory usage
• Initial state sync can be larger | +| Development | • No need to build conflict resolution
• Simple integration with text editors
• Future-proof architecture | • Team needs to learn new concepts
• Fewer ready-made solutions
• May need to build some features from scratch | +| Maintenance | • Less server infrastructure
• Simpler deployment
• Fewer points of failure | • Debugging can be more complex
• State management requires careful handling | +| Business Impact | • Better offline support for users
• Scales well as user base grows
• No licensing costs (with Yjs) | • Initial development time might be longer
• Team training required | + +#### Yjs +- **Type**: State-based CRDT +- **Implementation**: JavaScript/TypeScript +- **Features**: + - Rich text collaboration + - Shared types (Array, Map, XML) + - Binary encoding + - P2P support +- **Performance**: Excellent for text editing +- **Memory Usage**: Optimized +- **License**: MIT + +#### Automerge +- **Type**: Operation-based CRDT +- **Implementation**: JavaScript/Rust +- **Features**: + - JSON-like data structures + - Change history + - Undo/Redo + - Binary format +- **Performance**: Good, with Rust backend +- **Memory Usage**: Higher than Yjs +- **License**: MIT + +#### Legion +- **Type**: State-based CRDT +- **Implementation**: Rust with JS bindings +- **Features**: + - High performance + - Memory efficient + - Binary protocol +- **Performance**: Excellent +- **Memory Usage**: Very efficient +- **License**: Apache 2.0 + +#### Diamond Types +- **Type**: Operation-based CRDT +- **Implementation**: TypeScript +- **Features**: + - Specialized for text + - Small memory footprint + - Simple API +- **Performance**: Good for text +- **Memory Usage**: Efficient +- **License**: MIT + +Comparison Table: + +| Feature | Yjs | Automerge | Legion | Diamond Types | +|---------|-----|-----------|--------|---------------| +| Text Editing | ✅ Excellent | ✅ Good | ⚠️ Basic | ✅ Excellent | +| Structured Data | ✅ | ✅ | ✅ | ⚠️ | +| Memory Efficiency | ✅ High | ⚠️ Medium | ✅ Very High | ✅ High | +| Network Efficiency | ✅ | ⚠️ | ✅ | ✅ | +| Maturity | ✅ | ✅ | ⚠️ | ⚠️ | +| Community Size | ✅ Large | ✅ Large | ⚠️ Small | ⚠️ Small | +| Documentation | ✅ | ✅ | ⚠️ | ⚠️ | +| Backend Options | ✅ Many | ✅ Many | ⚠️ Limited | ⚠️ Limited | + +Key Differences: +1. **Implementation Approach**: + - Yjs: Optimized for text and rich-text editing + - Automerge: General-purpose JSON CRDT + - Legion: Performance-focused with Rust + - Diamond Types: Specialized for text collaboration + +2. **Performance Characteristics**: + - Yjs: Best for text editing scenarios + - Automerge: Good all-around performance + - Legion: Excellent raw performance + - Diamond Types: Optimized for text + +3. **Ecosystem Integration**: + - Yjs: Wide range of integrations + - Automerge: Good JavaScript ecosystem + - Legion: Limited but growing + - Diamond Types: Focused on text editors + +This analysis reinforces our choice of Yjs for the CRDT-based option as it provides: +- Best-in-class text editing performance +- Mature ecosystem +- Active community +- Excellent documentation +- Wide range of backend options + +## Decision + +After evaluating the alternatives, we choose Yjs for the following reasons: + +1. **Technical Fit:** +- Native CRDT support ensures reliable collaboration +- Excellent offline capabilities +- Good performance characteristics +- Flexible backend integration options + +2. **Project Requirements Match:** +- Easy integration with our Django backend +- Supports our core collaborative features +- Manageable learning curve for the team + +3. **Community & Support:** +- Active development +- Growing community +- Good documentation +- Open source with MIT license + +### Comparison of Key Features: + +| Feature | Yjs (CRDT) | ProseMirror | ShareDB | Convergence | +|---------|-----|-------------|----------|-------------| +| Real-time Collaboration | ✅ | ✅ | ✅ | ✅ | +| Offline Support | ✅ | ⚠️ | ⚠️ | ✅ | +| Django Integration | Easy | Complex | Complex | Moderate | +| Learning Curve | Medium | High | High | Medium | +| Cost | Free | Free | Free | Paid | +| Community Size | Growing | Large | Medium | Small | + +## Consequences + +### Positive +- Simplified implementation of real-time collaboration +- Good developer experience +- Future-proof technology choice +- No licensing costs + +### Negative +- Team needs to learn CRDT concepts +- Newer technology compared to alternatives +- May need to build some features available out-of-the-box in other solutions + +### Risks +- Community support might not grow as expected +- May discover limitations as we scale \ No newline at end of file diff --git a/submissions/devoteam/docs/docs/architecture.md b/submissions/devoteam/docs/docs/architecture.md new file mode 100644 index 00000000..230d3245 --- /dev/null +++ b/submissions/devoteam/docs/docs/architecture.md @@ -0,0 +1,19 @@ +## Architecture + +### Global system architecture + +```mermaid +flowchart TD + User -- HTTP --> Front("Frontend (NextJS SPA)") + Front -- REST API --> Back("Backend (Django)") + Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back + Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect") + Back -- REST API --> Yserver + Back --> DB("Database (PostgreSQL)") + Back <--> Celery --> DB + Back ----> S3("Minio (S3)") +``` + +### Architecture decision records + +- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md) \ No newline at end of file diff --git a/submissions/devoteam/docs/docs/assets/banner-docs.png b/submissions/devoteam/docs/docs/assets/banner-docs.png new file mode 100644 index 00000000..22e9d71c Binary files /dev/null and b/submissions/devoteam/docs/docs/assets/banner-docs.png differ diff --git a/submissions/devoteam/docs/docs/assets/docs-logo.png b/submissions/devoteam/docs/docs/assets/docs-logo.png new file mode 100644 index 00000000..fe590844 Binary files /dev/null and b/submissions/devoteam/docs/docs/assets/docs-logo.png differ diff --git a/submissions/devoteam/docs/docs/assets/docs_live_collaboration_light.gif b/submissions/devoteam/docs/docs/assets/docs_live_collaboration_light.gif new file mode 100644 index 00000000..0eaa1d16 Binary files /dev/null and b/submissions/devoteam/docs/docs/assets/docs_live_collaboration_light.gif differ diff --git a/submissions/devoteam/docs/docs/assets/europe_opensource.png b/submissions/devoteam/docs/docs/assets/europe_opensource.png new file mode 100644 index 00000000..00606b5e Binary files /dev/null and b/submissions/devoteam/docs/docs/assets/europe_opensource.png differ diff --git a/submissions/devoteam/docs/docs/assets/footer-configurable.png b/submissions/devoteam/docs/docs/assets/footer-configurable.png new file mode 100644 index 00000000..c79ebea1 Binary files /dev/null and b/submissions/devoteam/docs/docs/assets/footer-configurable.png differ diff --git a/submissions/devoteam/docs/docs/assets/logo.png b/submissions/devoteam/docs/docs/assets/logo.png new file mode 100644 index 00000000..949969ff Binary files /dev/null and b/submissions/devoteam/docs/docs/assets/logo.png differ diff --git a/submissions/devoteam/docs/docs/env.md b/submissions/devoteam/docs/docs/env.md new file mode 100644 index 00000000..a41d7ef9 --- /dev/null +++ b/submissions/devoteam/docs/docs/env.md @@ -0,0 +1,143 @@ +# Docs variables + +Here we describe all environment variables that can be set for the docs application. + +## impress-backend container + +These are the environment variables you can set for the `impress-backend` container. + +| Option | Description | default | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| DJANGO_ALLOWED_HOSTS | allowed hosts | [] | +| DJANGO_SECRET_KEY | secret key | | +| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | +| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 | +| DB_NAME | name of the database | impress | +| DB_USER | user to authenticate with | dinum | +| DB_PASSWORD | password to authenticate with | pass | +| DB_HOST | host of the database | localhost | +| DB_PORT | port of the database | 5432 | +| MEDIA_BASE_URL | | | +| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | +| AWS_S3_ENDPOINT_URL | S3 endpoint | | +| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | | +| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | | +| AWS_S3_REGION_NAME | region name for s3 endpoint | | +| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage | +| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 | +| LANGUAGE_CODE | default language | en-us | +| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour | +| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute | +| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false | +| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 | +| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend | +| DJANGO_EMAIL_BRAND_NAME | brand name for email | | +| DJANGO_EMAIL_HOST | host name of email | | +| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | | +| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | | +| DJANGO_EMAIL_LOGO_IMG | logo for the email | | +| DJANGO_EMAIL_PORT | port used to connect to email host | | +| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false | +| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false | +| DJANGO_EMAIL_FROM | email address used as sender | from@example.com | +| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true | +| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] | +| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] | +| SENTRY_DSN | sentry host | | +| COLLABORATION_API_URL | collaboration api host | | +| COLLABORATION_SERVER_SECRET | collaboration api secret | | +| COLLABORATION_WS_URL | collaboration websocket url | | +| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | +| FRONTEND_CSS_URL | To add a external css file to the app | | +| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false | +| FRONTEND_THEME | frontend theme to use | | +| POSTHOG_KEY | posthog key for analytics | | +| CRISP_WEBSITE_ID | crisp website id for support | | +| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 | +| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} | +| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | +| OIDC_CREATE_USER | create used on OIDC | false | +| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | +| OIDC_RP_CLIENT_ID | client id used for OIDC | impress | +| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | | +| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | | +| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | +| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | | +| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | +| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | | +| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | +| OIDC_RP_SCOPES | scopes requested for OIDC | openid email | +| LOGIN_REDIRECT_URL | login redirect url | | +| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | | +| LOGOUT_REDIRECT_URL | logout redirect url | | +| OIDC_USE_NONCE | use nonce for OIDC | true | +| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | +| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | +| OIDC_STORE_ID_TOKEN | Store OIDC token | true | +| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true | +| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | +| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] | +| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | +| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | +| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | +| AI_API_KEY | AI key to be used for AI Base url | | +| AI_BASE_URL | OpenAI compatible AI base url | | +| AI_MODEL | AI Model to use | | +| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | +| AI_FEATURE_ENABLED | Enable AI options | false | +| Y_PROVIDER_API_KEY | Y provider API key | | +| Y_PROVIDER_API_BASE_URL | Y Provider url | | +| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown | +| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | +| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | +| CONVERSION_API_SECURE | Require secure conversion api | false | +| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | +| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | +| API_USERS_LIST_LIMIT | Limit on API users | 5 | +| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | +| REDIS_URL | cache url | redis://redis:6379/1 | +| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 | +| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | +| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | +| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | +| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | +| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | + + +## impress-frontend image + +These are the environment variables you can set to build the `impress-frontend` image. + +Depending on how you are building the front-end application, this variable is used in different ways. + +If you want to build the Docker image, this variable is used as an argument in the build command. + +Example: + +``` +docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest +``` + +If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`). + +Example: + +``` +cd src/frontend/apps/impress +NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build +``` + +| Option | Description | default | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| API_ORIGIN | backend domain - it uses the current domain if not initialized | | +| SW_DEACTIVATED | To not install the service worker | | +| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true | + +Packages with licences incompatible with the MIT licence: +* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE), +* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) + +In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features. + +⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations. + diff --git a/submissions/devoteam/docs/docs/examples/impress.values.yaml b/submissions/devoteam/docs/docs/examples/impress.values.yaml new file mode 100644 index 00000000..ce28e4c4 --- /dev/null +++ b/submissions/devoteam/docs/docs/examples/impress.values.yaml @@ -0,0 +1,163 @@ +image: + repository: lasuite/impress-backend + pullPolicy: Always + tag: "latest" + +backend: + replicas: 1 + envVars: + COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/ + COLLABORATION_SERVER_SECRET: my-secret + DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io + DJANGO_CONFIGURATION: Feature + DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io + DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key + DJANGO_SECRET_KEY: AgoodOrAbadKey + DJANGO_SETTINGS_MODULE: impress.settings + DJANGO_SUPERUSER_PASSWORD: admin + DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique" + DJANGO_EMAIL_HOST: "mailcatcher" + DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png + DJANGO_EMAIL_PORT: 1025 + DJANGO_EMAIL_USE_SSL: False + LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR + LOGGING_LEVEL_LOGGERS_ROOT: INFO + LOGGING_LEVEL_LOGGERS_APP: INFO + OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs + OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth + OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token + OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo + OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end + OIDC_RP_CLIENT_ID: impress + OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly + OIDC_RP_SIGN_ALGO: RS256 + OIDC_RP_SCOPES: "openid email" + OIDC_VERIFY_SSL: False + OIDC_USERINFO_SHORTNAME_FIELD: "given_name" + OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name" + OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io + OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" + LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io + LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io + LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io + POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}" + DB_HOST: postgresql + DB_NAME: impress + DB_USER: dinum + DB_PASSWORD: pass + DB_PORT: 5432 + POSTGRES_DB: impress + POSTGRES_USER: dinum + POSTGRES_PASSWORD: pass + REDIS_URL: redis://default:pass@redis-master:6379/1 + AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000 + AWS_S3_ACCESS_KEY_ID: root + AWS_S3_SECRET_ACCESS_KEY: password + AWS_STORAGE_BUCKET_NAME: impress-media-storage + STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/ + Y_PROVIDER_API_KEY: my-secret + + migrate: + command: + - "/bin/sh" + - "-c" + - | + python manage.py migrate --no-input && + python manage.py create_demo --force + restartPolicy: Never + + command: + - "gunicorn" + - "-c" + - "/usr/local/etc/gunicorn/impress.py" + - "impress.wsgi:application" + - "--reload" + + createsuperuser: + command: + - "/bin/sh" + - "-c" + - | + python manage.py createsuperuser --email admin@example.com --password admin + restartPolicy: Never + + # Extra volume to manage our local custom CA and avoid to set ssl_verify: false + extraVolumeMounts: + - name: certs + mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem + subPath: cacert.pem + + # Extra volume to manage our local custom CA and avoid to set ssl_verify: false + extraVolumes: + - name: certs + configMap: + name: certifi + items: + - key: cacert.pem + path: cacert.pem +frontend: + envVars: + PORT: 8080 + NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io + + replicas: 1 + + image: + repository: lasuite/impress-frontend + pullPolicy: Always + tag: "latest" + +yProvider: + replicas: 1 + + image: + repository: lasuite/impress-y-provider + pullPolicy: Always + tag: "latest" + + envVars: + COLLABORATION_LOGGING: true + COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io + COLLABORATION_SERVER_SECRET: my-secret + Y_PROVIDER_API_KEY: my-secret + +posthog: + ingress: + enabled: false + ingressAssets: + enabled: false + +ingress: + enabled: true + host: impress.127.0.0.1.nip.io + +ingressCollaborationWS: + enabled: true + host: impress.127.0.0.1.nip.io + + annotations: + nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/ + +ingressCollaborationApi: + enabled: true + host: impress.127.0.0.1.nip.io + +ingressAdmin: + enabled: true + host: impress.127.0.0.1.nip.io + +ingressMedia: + enabled: true + host: impress.127.0.0.1.nip.io + + annotations: + nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/ + nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" + nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000 + nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1 + +serviceMedia: + host: minio.impress.svc.cluster.local + port: 9000 + diff --git a/submissions/devoteam/docs/docs/examples/keycloak.values.yaml b/submissions/devoteam/docs/docs/examples/keycloak.values.yaml new file mode 100644 index 00000000..0e9ae57b --- /dev/null +++ b/submissions/devoteam/docs/docs/examples/keycloak.values.yaml @@ -0,0 +1,2299 @@ +postgresql: + auth: + username: keycloak + password: keycloak + database: keycloak +extraEnvVars: + - name: KEYCLOAK_EXTRA_ARGS + value: "--import-realm" + - name: KC_HOSTNAME_URL + value: https://keycloak.127.0.0.1.nip.io +extraVolumes: + - name: import + configMap: + name: impress-keycloak +extraVolumeMounts: + - name: import + mountPath: /opt/bitnami/keycloak/data/import/ +auth: + adminUser: su + adminPassword: su +proxy: edge +ingress: + enabled: true + hostname: keycloak.127.0.0.1.nip.io +extraDeploy: +- apiVersion: v1 + kind: ConfigMap + metadata: + name: impress-keycloak + data: + impress.json: | + { + "id": "ccf4fd40-4286-474d-854a-4714282a8bec", + "realm": "impress", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": "false", + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": "false", + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": "true", + "sslRequired": "external", + "registrationAllowed": "true", + "registrationEmailAsUsername": "false", + "rememberMe": "true", + "verifyEmail": "false", + "loginWithEmailAllowed": "true", + "duplicateEmailsAllowed": "false", + "resetPasswordAllowed": "true", + "editUsernameAllowed": "false", + "bruteForceProtected": "false", + "permanentLockout": "false", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "users": [ + { + "username": "impress", + "email": "impress@meet.world", + "firstName": "John", + "lastName": "Doe", + "enabled": "true", + "credentials": [ + { + "type": "password", + "value": "impress" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user-e2e-chromium", + "email": "user@chromium.e2e", + "firstName": "E2E", + "lastName": "Chromium", + "enabled": "true", + "credentials": [ + { + "type": "password", + "value": "password-e2e-chromium" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user-e2e-webkit", + "email": "user@webkit.e2e", + "firstName": "E2E", + "lastName": "Webkit", + "enabled": "true", + "credentials": [ + { + "type": "password", + "value": "password-e2e-webkit" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user-e2e-firefox", + "email": "user@firefox.e2e", + "firstName": "E2E", + "lastName": "Firefox", + "enabled": "true", + "credentials": [ + { + "type": "password", + "value": "password-e2e-firefox" + } + ], + "realmRoles": ["user"] + } + ], + "roles": { + "realm": [ + { + "id": "1f116065-05b6-4269-80a6-c7d904b584b7", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": "false", + "clientRole": "false", + "containerId": "ccf4fd40-4286-474d-854a-4714282a8bec", + "attributes": {} + }, + { + "id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99", + "name": "default-roles-impress", + "description": "${role_default-roles}", + "composite": "true", + "composites": { + "realm": ["offline_access", "uma_authorization"], + "client": { + "account": ["view-profile", "manage-account"] + } + }, + "clientRole": "false", + "containerId": "ccf4fd40-4286-474d-854a-4714282a8bec", + "attributes": {} + }, + { + "id": "8733db03-278a-45ad-a25e-c167fbd95b5a", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": "false", + "clientRole": "false", + "containerId": "ccf4fd40-4286-474d-854a-4714282a8bec", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "9dcc0883-e2e5-4671-9159-402bdbe73c57", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "ae911be0-ea2e-466d-93e0-f8e73fa8f444", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "e777d332-7205-4b76-8b21-9191a2e85a0d", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "b1a95608-d518-4ede-936e-525ab704d363", + "name": "create-client", + "description": "${role_create-client}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "ac58976a-ae55-4d92-a864-b33e21b07c54", + "name": "view-events", + "description": "${role_view-events}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "a149b28f-d252-4ceb-8ba9-8161603c4184", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "00a5b886-7ca4-4fba-90c6-a9071e697d86", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "b22d5cc1-879e-4405-8345-cc204fd0fec0", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": "true", + "composites": { + "client": { + "realm-management": [ + "impersonation", + "view-authorization", + "manage-authorization", + "create-client", + "view-events", + "manage-identity-providers", + "manage-clients", + "view-identity-providers", + "query-users", + "manage-users", + "view-clients", + "view-users", + "manage-events", + "view-realm", + "query-realms", + "query-groups", + "manage-realm", + "query-clients" + ] + } + }, + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "b3e9faf6-17bf-4f62-abd5-07837806a7e6", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "a8d85f42-023b-48dd-8f49-c9da2b5317ee", + "name": "query-users", + "description": "${role_query-users}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "eb325a4d-db7a-4f6a-a88b-0ff8aa38b0a5", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "267bb612-62f4-4354-abb2-ac6a34bd854b", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": "true", + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "b575be2b-e250-4000-b75e-3038cda8c0dd", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "e19cd0bf-8da0-457d-b630-454c611bc1ba", + "name": "view-users", + "description": "${role_view-users}", + "composite": "true", + "composites": { + "client": { + "realm-management": ["query-users", "query-groups"] + } + }, + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "c12145cc-cbdc-4ef3-9774-19b1852811ba", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "e7e15b84-4971-4c13-be93-315bb36d30e1", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "e03d2989-a620-4918-85ed-3eabd0373bb4", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "daf8d347-4b30-41d6-a431-7b3723dd8e6f", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + }, + { + "id": "432cd3eb-4741-46ba-938a-94ff9dece315", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": "false", + "clientRole": "true", + "containerId": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "2e713186-38da-44d7-a5a5-19d91ef2dfca", + "name": "read-token", + "description": "${role_read-token}", + "composite": "false", + "clientRole": "true", + "containerId": "41dd8f26-46c2-471a-859e-01886f972ff9", + "attributes": {} + } + ], + "impress": [], + "account": [ + { + "id": "63b1a4e1-a594-4571-99c3-7c5c3efd61ce", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": "true", + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": "true", + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "36ef5fd6-1167-4ba0-9171-c8cb6cfe904b", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": "false", + "clientRole": "true", + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "f984654a-fca5-45d9-bb47-73009eb9bcf0", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": "false", + "clientRole": "true", + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "d54168c5-58a5-4f13-9fa8-6dbbee0e4b73", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": "true", + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": "true", + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "092b6808-1ee2-44be-9b5d-085ccd6862b4", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": "false", + "clientRole": "true", + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "ddd57af0-2a5e-4f9d-98e5-ec96c8d852ce", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": "false", + "clientRole": "true", + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "84c7324a-4724-41fe-8bd4-848ce5cebd5b", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": "false", + "clientRole": "true", + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + }, + { + "id": "20d06f75-ea65-4b99-b9ef-2384ffd1de53", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": "false", + "clientRole": "true", + "containerId": "06721011-1061-4ca7-944f-be2a20719e20", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99", + "name": "default-roles-impress", + "description": "${role_default-roles}", + "composite": "true", + "clientRole": "false", + "containerId": "ccf4fd40-4286-474d-854a-4714282a8bec" + }, + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": "false", + "otpSupportedApplications": ["totpAppGoogleName", "totpAppFreeOTPName"], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": "false", + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": "false", + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account", "view-groups"] + } + ] + }, + "clients": [ + { + "id": "06721011-1061-4ca7-944f-be2a20719e20", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/impress/account/", + "surrogateAuthRequired": "false", + "enabled": "true", + "alwaysDisplayInConsole": "false", + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/impress/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": "false", + "consentRequired": "false", + "standardFlowEnabled": "true", + "implicitFlowEnabled": "false", + "directAccessGrantsEnabled": "false", + "serviceAccountsEnabled": "false", + "publicClient": "true", + "frontchannelLogout": "false", + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": "false", + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "987e14a5-caed-40a6-8bac-8c429b74ca48", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/impress/account/", + "surrogateAuthRequired": "false", + "enabled": "true", + "alwaysDisplayInConsole": "false", + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/impress/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": "false", + "consentRequired": "false", + "standardFlowEnabled": "true", + "implicitFlowEnabled": "false", + "directAccessGrantsEnabled": "false", + "serviceAccountsEnabled": "false", + "publicClient": "true", + "frontchannelLogout": "false", + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": "false", + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "4f958126-eaa1-46d5-967a-3a3c2e2d11f7", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": "false", + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "92da37ad-e8a1-41f1-93c6-541dffa7d601", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": "false", + "enabled": "true", + "alwaysDisplayInConsole": "false", + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": "false", + "consentRequired": "false", + "standardFlowEnabled": "false", + "implicitFlowEnabled": "false", + "directAccessGrantsEnabled": "true", + "serviceAccountsEnabled": "false", + "publicClient": "true", + "frontchannelLogout": "false", + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": "false", + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "41dd8f26-46c2-471a-859e-01886f972ff9", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": "false", + "enabled": "true", + "alwaysDisplayInConsole": "false", + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": "true", + "consentRequired": "false", + "standardFlowEnabled": "true", + "implicitFlowEnabled": "false", + "directAccessGrantsEnabled": "false", + "serviceAccountsEnabled": "false", + "publicClient": "false", + "frontchannelLogout": "false", + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": "false", + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "869481d0-5774-4e64-bc30-fedc7c58958f", + "clientId": "impress", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": "false", + "enabled": "true", + "alwaysDisplayInConsole": "false", + "clientAuthenticatorType": "client-secret", + "secret": "ThisIsAnExampleKeyForDevPurposeOnly", + "redirectUris": [ + "http://localhost:8070/*", + "http://localhost:8071/*", + "https://impress.127.0.0.1.nip.io/*", + "http://localhost:8088/*", + "http://localhost:3000/*" + ], + "webOrigins": [ + "https://impress.127.0.0.1.nip.io", + "http://localhost:8088", + "http://localhost:8070", + "http://localhost:3000" + ], + "notBefore": 0, + "bearerOnly": "false", + "consentRequired": "false", + "standardFlowEnabled": "true", + "implicitFlowEnabled": "false", + "directAccessGrantsEnabled": "false", + "serviceAccountsEnabled": "false", + "publicClient": "false", + "frontchannelLogout": "true", + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "-1", + "client.secret.creation.time": "1707820779", + "user.info.response.signature.alg": "RS256", + "post.logout.redirect.uris": "http://localhost:8070/*##https://impress.127.0.0.1.nip.io/*##http://localhost:3000/*", + "oauth2.device.authorization.grant.enabled": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "tls-client-certificate-bound-access-tokens": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "acr.loa.map": "{}", + "require.pushed.authorization.requests": "false", + "display.on.consent.screen": "false", + "client.session.idle.timeout": "-1", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": "true", + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0d004a05-7049-452c-83a8-2bae2b5d8015", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": "false", + "enabled": "true", + "alwaysDisplayInConsole": "false", + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": "true", + "consentRequired": "false", + "standardFlowEnabled": "true", + "implicitFlowEnabled": "false", + "directAccessGrantsEnabled": "false", + "serviceAccountsEnabled": "false", + "publicClient": "false", + "frontchannelLogout": "false", + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": "false", + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "2a4e007a-2fc4-4f43-aace-b93aec9221b4", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/impress/console/", + "surrogateAuthRequired": "false", + "enabled": "true", + "alwaysDisplayInConsole": "false", + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/admin/impress/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": "false", + "consentRequired": "false", + "standardFlowEnabled": "true", + "implicitFlowEnabled": "false", + "directAccessGrantsEnabled": "false", + "serviceAccountsEnabled": "false", + "publicClient": "true", + "frontchannelLogout": "false", + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": "false", + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "4913be96-5827-46a4-9909-562c2dd5bef6", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "74aeb8e2-a1b6-4897-9eaf-d922becea170", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "994b8f5e-dfc1-4154-a936-347336e6422a", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": "false", + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "d853f97e-80f8-470e-8447-815b289d9ae3", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": "false", + "config": {} + }, + { + "id": "26a9f3ef-cff0-4dee-9fe9-778cd1d2a771", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": "false", + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "af52ccc3-4ecb-49b4-9a67-5d4172f16070", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "efb82630-8835-4de0-944e-ac5ea51eca48", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": "false", + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "2256189a-7970-4244-b496-64cbba3ce582", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "6d7f8b9e-997e-40f8-bae5-83d2647fbeff", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": "false", + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "b83cebb6-f086-48e2-8e5a-9802736342f2", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "b99113c6-ccfb-43d4-acd1-09dd34cdf5bc", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "696211d7-c434-495f-b3a0-a1b88bebfd6e", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": "false", + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "16845bd9-5626-4484-b4c5-00af52d8ad8b", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "5828a7d9-cdc7-456b-a747-16bf83c2f57d", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": "false", + "config": {} + } + ] + }, + { + "id": "ce289e05-eca4-4323-b457-822d39cc6d49", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "abe63488-9a39-4e29-a0a8-824db0887b60", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "15690cfb-e14c-46e8-8494-22a0365a4b0c", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "03cf0e4c-c2a5-4203-88c4-5391d361ba15", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "23b1a1da-2ecc-4db7-8d33-4e9233a81e89", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "26a72777-56eb-4b46-acca-eca8168e29fc", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "4ae1896b-ea82-4604-8f0e-72133fdee05c", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b", + "name": "first name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "first_name", + "jsonType.label": "String" + } + }, + { + "id": "6397c5e9-95ea-4c31-bd44-a8acf1d18472", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "7f741e96-41fe-4021-bbfd-506e7eb94e69", + "name": "last name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "last_name", + "jsonType.label": "String" + } + }, + { + "id": "5ca62964-2d04-4e8e-963d-e3b08cf32d7c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "954a5dff-cc19-4dde-b996-787f767db4cc", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": "false", + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "1eba19bf-6fa1-4608-ad2d-d4346580c93d", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "e7bdd267-fcce-451f-b3e1-a775cf611dd2", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "a9a8918c-af00-48a5-a8b3-a28a83653f71", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "cd725067-b6ba-42f1-a940-97a16a23cb85", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "a4e1812c-4093-4666-a6b3-03c5d9b5ca9f", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "d6690292-74d1-48ac-855d-2f0f3799829e", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": "false", + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "ce8f1215-0462-4e87-8a3b-18488aee0267", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "0ce95430-80aa-4dd6-994b-5a67302ba531", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "8da0d3b1-d609-417e-9adc-1de77549baf9", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "f89a9158-7c03-49b0-8a3c-d0b75e2ce1b4", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "fb109597-e31e-46d7-84c5-62e5fcf32ac8", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "61c135e5-2447-494b-bc70-9612f383be27", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": "false", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": "false", + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": "false", + "adminEventsDetailsEnabled": "false", + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "74dffa9a-5d4f-4ce3-9708-885212f56861", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "48096073-ceae-4e68-a15b-f1aa390dcce5", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "51b0e87c-ee04-4664-a299-f8e49cb7a9ac", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "6379b091-2289-4fe7-894c-c03f1bd0e69b", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "97ae8320-a439-463b-817e-05bd4a6c39d1", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper" + ] + } + }, + { + "id": "49131ffc-4831-4e3e-a466-f9f08aa1bee0", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "e12647d2-e21f-49bc-a8c6-28154c5544d2", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper", + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "c9f00ef2-00d9-44bd-9b6c-3b3bf57e44ba", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "96260850-72a5-4b49-b96b-5a33d0b5337d", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "55d93b4d-fe05-46a1-a832-36f380aaddf7", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": ["100"] + } + }, + { + "id": "bee288b4-ecdf-4ec4-8c31-ee330f1e8f95", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": ["100"], + "algorithm": ["HS256"] + } + }, + { + "id": "2aa8f54d-8b4b-4eb7-a05b-89211f544358", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": ["100"], + "algorithm": ["RSA-OAEP"] + } + }, + { + "id": "23ad48f4-2275-4a0d-aa0d-1e0691f9c620", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": ["100"] + } + } + ] + }, + "internationalizationEnabled": "false", + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "0c349304-21fd-47ff-8dc6-46efb107b7e9", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": "false", + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": "true", + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "cf1ed416-7274-4804-88bf-4261b0bacdc6", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "basic-auth-otp", + "authenticatorFlow": "false", + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": "false", + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "d949f1f1-4622-49ec-b74a-4b8a58c653d2", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "3deb6d9d-2064-410c-af99-b1601cd9b1c4", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "f777c4be-f7d1-453e-a9d7-a2a235b7975b", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "1bc12f49-e2ef-42bd-959a-0983e1cd4d65", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "true", + "flowAlias": "Account verification options", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "324cdcf5-8f31-4768-9db9-63208f182b39", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "23d17138-8ebd-4195-91d3-614094f62070", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": "false", + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": "true", + "flowAlias": "Handle Existing Account", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "61fec72a-bfd2-42e8-95c1-fa0b76c1cd2b", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": "true", + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "dc00b9a8-fc37-4591-a1ea-07c7f884d394", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": "false", + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": "false", + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": "false", + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": "true", + "flowAlias": "forms", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "4f27245a-49b8-4870-a5e2-f0ea624a792c", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": "false", + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": "false", + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": "false", + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "client-x509", + "authenticatorFlow": "false", + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "5b2c66e1-7bbf-4707-9db8-244269b68164", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": "true", + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "4bcddec4-4260-4f4f-a757-3aff9b1d30f3", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "04a94e38-b7fb-48f6-8d63-5640f835c619", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "true", + "flowAlias": "User creation or linking", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "bfcf5112-96ac-485a-8663-b02ad41af919", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": "true", + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "e262d10d-ad0d-4d18-bc05-3a44f7d21736", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "true", + "flowAlias": "Authentication Options", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "b671c4b3-22b6-4aac-a1d1-464a2101767c", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": "true", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "true", + "flowAlias": "registration form", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "f570e064-0e62-4eae-8087-8b06751b8f33", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": "false", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 40, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": "false", + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "07124099-1d10-4148-ac06-4b0b700908da", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticator": "reset-password", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + }, + { + "authenticatorFlow": "true", + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": "true", + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": "false" + } + ] + }, + { + "id": "0a5fa089-f987-4903-9170-36565edda152", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": "true", + "builtIn": "true", + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": "false", + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": "false", + "userSetupAllowed": "false" + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "d2818365-2189-4003-9817-0ad5368e37f3", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "72508559-0176-4eee-a77e-0795d652be12", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": "true", + "defaultAction": "false", + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": "false", + "defaultAction": "false", + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": "true", + "defaultAction": "false", + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": "true", + "defaultAction": "false", + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": "true", + "defaultAction": "false", + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": "false", + "defaultAction": "false", + "priority": 60, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": "true", + "defaultAction": "false", + "priority": 70, + "config": {} + }, + { + "alias": "UPDATE_EMAIL", + "name": "Update Email", + "providerId": "UPDATE_EMAIL", + "enabled": "true", + "defaultAction": "false", + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": "true", + "defaultAction": "false", + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": "true", + "defaultAction": "false", + "priority": 80, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": "true", + "defaultAction": "false", + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "20.0.1", + "userManagedAccessAllowed": "false", + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } + } + + diff --git a/submissions/devoteam/docs/docs/examples/minio.values.yaml b/submissions/devoteam/docs/docs/examples/minio.values.yaml new file mode 100644 index 00000000..e006f2a5 --- /dev/null +++ b/submissions/devoteam/docs/docs/examples/minio.values.yaml @@ -0,0 +1,8 @@ +auth: + rootUser: root + rootPassword: password +provisioning: + enabled: true + buckets: + - name: impress-media-storage + versioning: true diff --git a/submissions/devoteam/docs/docs/examples/postgresql.values.yaml b/submissions/devoteam/docs/docs/examples/postgresql.values.yaml new file mode 100644 index 00000000..79a4ae4f --- /dev/null +++ b/submissions/devoteam/docs/docs/examples/postgresql.values.yaml @@ -0,0 +1,7 @@ +auth: + username: dinum + password: pass + database: impress +tls: + enabled: true + autoGenerated: true diff --git a/submissions/devoteam/docs/docs/examples/redis.values.yaml b/submissions/devoteam/docs/docs/examples/redis.values.yaml new file mode 100644 index 00000000..9e8a5bf2 --- /dev/null +++ b/submissions/devoteam/docs/docs/examples/redis.values.yaml @@ -0,0 +1,4 @@ +auth: + password: pass +architecture: standalone + diff --git a/submissions/devoteam/docs/docs/installation.md b/submissions/devoteam/docs/docs/installation.md new file mode 100644 index 00000000..65e7c8da --- /dev/null +++ b/submissions/devoteam/docs/docs/installation.md @@ -0,0 +1,230 @@ +# Installation on a k8s cluster + +This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it works. It needs to be adapted for a production environment. + +## Prerequisites + +- k8s cluster with an nginx-ingress controller +- an OIDC provider (if you don't have one, we provide an example) +- a PostgreSQL server (if you don't have one, we provide an example) +- a Memcached server (if you don't have one, we provide an example) +- a S3 bucket (if you don't have one, we provide an example) + +### Test cluster + +If you do not have a test cluster, you can install everything on a local Kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**. + +To be able to use the script, you need to install: + +- Docker (https://docs.docker.com/desktop/) +- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation) +- Mkcert (https://github.com/FiloSottile/mkcert#installation) +- Helm (https://helm.sh/docs/intro/quickstart/#install-helm) + +``` +./bin/start-kind.sh + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed +100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000 +0. Create ca +The local CA is already installed in the system trust store! 👍 +The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍 + + +Created a new certificate valid for the following names 📜 + - "127.0.0.1.nip.io" + - "*.127.0.0.1.nip.io" + +Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io ℹ️ + +The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅ + +It will expire on 24 March 2027 🗓 + +1. Create registry container unless it already exists +2. Create kind cluster with containerd registry config dir enabled +Creating cluster "suite" ... + ✓ Ensuring node image (kindest/node:v1.27.3) 🖼 + ✓ Preparing nodes 📦 + ✓ Writing configuration 📜 + ✓ Starting control-plane 🕹️ + ✓ Installing CNI 🔌 + ✓ Installing StorageClass 💾 +Set kubectl context to "kind-suite" +You can now use your cluster with: + +kubectl cluster-info --context kind-suite + +Thanks for using kind! 😊 +3. Add the registry config to the nodes +4. Connect the registry to the cluster network if not already connected +5. Document the local registry +configmap/local-registry-hosting created +Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically. +configmap/coredns configured +deployment.apps/coredns restarted +6. Install ingress-nginx +namespace/ingress-nginx created +serviceaccount/ingress-nginx created +serviceaccount/ingress-nginx-admission created +role.rbac.authorization.k8s.io/ingress-nginx created +role.rbac.authorization.k8s.io/ingress-nginx-admission created +clusterrole.rbac.authorization.k8s.io/ingress-nginx created +clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created +rolebinding.rbac.authorization.k8s.io/ingress-nginx created +rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created +clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created +clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created +configmap/ingress-nginx-controller created +service/ingress-nginx-controller created +service/ingress-nginx-controller-admission created +deployment.apps/ingress-nginx-controller created +job.batch/ingress-nginx-admission-create created +job.batch/ingress-nginx-admission-patch created +ingressclass.networking.k8s.io/nginx created +validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created +secret/mkcert created +deployment.apps/ingress-nginx-controller patched +7. Setup namespace +namespace/impress created +Context "kind-suite" modified. +secret/mkcert created +$ kubectl -n ingress-nginx get po +NAME READY STATUS RESTARTS AGE +ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s +ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s +ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s +``` + +When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the `*.127.0.0.1.nip.io` domain and mkcert certificates to have full HTTPS support and easy domain name management. + +Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP. + +## Preparation + +### What do you use to authenticate your users? + +Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo). + +``` +$ kubectl create namespace impress +$ kubectl config set-context --current --namespace=impress +$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml +$ #wait until +$ kubectl get po +NAME READY STATUS RESTARTS AGE +keycloak-0 1/1 Running 0 6m48s +keycloak-postgresql-0 1/1 Running 0 6m48s +``` + +From here the important information you will need are: + +```yaml +OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs +OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth +OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token +OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo +OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end +OIDC_RP_CLIENT_ID: impress +OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly +OIDC_RP_SIGN_ALGO: RS256 +OIDC_RP_SCOPES: "openid email" +``` + +You can find these values in **examples/keycloak.values.yaml** + +### Find redis server connection values + +Docs needs a redis so we start by deploying one: + +``` +$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml +$ kubectl get po +NAME READY STATUS RESTARTS AGE +keycloak-0 1/1 Running 0 26m +keycloak-postgresql-0 1/1 Running 0 26m +redis-master-0 1/1 Running 0 35s +``` + +### Find postgresql connection values + +Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow: + +``` +$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml +$ kubectl get po +NAME READY STATUS RESTARTS AGE +keycloak-0 1/1 Running 0 28m +keycloak-postgresql-0 1/1 Running 0 28m +postgresql-0 1/1 Running 0 14m +redis-master-0 1/1 Running 0 42s +``` + +From here the important information you will need are: + +```yaml +DB_HOST: postgres-postgresql +DB_NAME: impress +DB_USER: dinum +DB_PASSWORD: pass +DB_PORT: 5432 +POSTGRES_DB: impress +POSTGRES_USER: dinum +POSTGRES_PASSWORD: pass +``` + +### Find s3 bucket connection values + +Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow: + +``` +$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml +$ kubectl get po +NAME READY STATUS RESTARTS AGE +keycloak-0 1/1 Running 0 38m +keycloak-postgresql-0 1/1 Running 0 38m +minio-84f5c66895-bbhsk 1/1 Running 0 42s +minio-provisioning-2b5sq 0/1 Completed 0 42s +postgresql-0 1/1 Running 0 24m +redis-master-0 1/1 Running 0 10m +``` + +## Deployment + +Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous information to the helm chart. + +``` +$ helm repo add impress https://suitenumerique.github.io/docs/ +$ helm repo update +$ helm install impress impress/docs -f examples/impress.values.yaml +$ kubectl get po +NAME READY STATUS RESTARTS AGE +impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s +impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s +impress-docs-backend-migrate-c949s 0/1 Completed 0 79s +impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s +impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s +keycloak-0 1/1 Running 0 48m +keycloak-postgresql-0 1/1 Running 0 48m +minio-84f5c66895-bbhsk 1/1 Running 0 10m +minio-provisioning-2b5sq 0/1 Completed 0 10m +postgresql-0 1/1 Running 0 34m +redis-master-0 1/1 Running 0 20m +``` + +## Test your deployment + +In order to test your deployment you have to log into your instance. If you exclusively use our examples you can do: + +``` +$ kubectl get ingress +NAME CLASS HOSTS ADDRESS PORTS AGE +impress-docs impress.127.0.0.1.nip.io localhost 80, 443 114s +impress-docs-admin impress.127.0.0.1.nip.io localhost 80, 443 114s +impress-docs-collaboration-api impress.127.0.0.1.nip.io localhost 80, 443 114s +impress-docs-media impress.127.0.0.1.nip.io localhost 80, 443 114s +impress-docs-ws impress.127.0.0.1.nip.io localhost 80, 443 114s +keycloak keycloak.127.0.0.1.nip.io localhost 80 49m +``` + +You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress. diff --git a/submissions/devoteam/docs/docs/release.md b/submissions/devoteam/docs/docs/release.md new file mode 100644 index 00000000..2364c10a --- /dev/null +++ b/submissions/devoteam/docs/docs/release.md @@ -0,0 +1,72 @@ +# Releasing a new version + +Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below: + +1. Create a new branch named: `release/4.18.1`. + 2. Bump the release number for backend project, frontend projects, and Helm files: + + - for backend, update the version number by hand in `pyproject.toml`, + - for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you, + - for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments: + + ```yaml + image: + repository: lasuite/impress-backend + pullPolicy: Always + tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix + + ... + + frontend: + image: + repository: lasuite/impress-frontend + pullPolicy: Always + tag: "v4.18.1" + + y-provider: + image: + repository: lasuite/impress-y-provider + pullPolicy: Always + tag: "v4.18.1" + ``` + + The new images don't exist _yet_: they will be created automatically later in the process. + +3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations + +4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version: + + ```text + 🔖(minor) bump release to 4.18.0 + ``` + +5. Open a pull request, wait for an approval from your peers and merge it. +6. Checkout and pull changes from the `main` branch to ensure you have the latest updates. +7. Tag and push your commit: + + ```bash + git tag v4.18.1 && git push origin tag v4.18.1 + ``` + + Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files. + +8. Ensure the new [backend](https://hub.docker.com/r/lasuite/impress-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/impress-frontend/tags) image tags are on Docker Hub. +9. The release is now done! + +# Deploying + +> [!TIP] +> The `staging` platform is deployed automatically with every update of the `main` branch. + +Making a new release doesn't publish it automatically in production. + +Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit. + +To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit. + +To publish the release you just made: + +```bash +git tag --force production v4.18.1 +git push --force origin production +``` \ No newline at end of file diff --git a/submissions/devoteam/docs/docs/theming.md b/submissions/devoteam/docs/docs/theming.md new file mode 100644 index 00000000..2c511d71 --- /dev/null +++ b/submissions/devoteam/docs/docs/theming.md @@ -0,0 +1,56 @@ +# Runtime Theming 🎨 + +### How to Use + +To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example: + +```javascript +FRONTEND_CSS_URL=http://anything/custom-style.css +``` + +Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application. + +### Benefits + +This feature provides several benefits, including: + +* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes. +* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs. +* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation. + +### Example Use Case + +Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents: + +```css +body { + background-color: #3498db; +} +``` + +Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified. + +---- + +# **Footer Configuration** 📝 + +The footer is configurable from the theme customization file. + +### Settings 🔧 + +```shellscript +THEME_CUSTOMIZATION_FILE_PATH= +``` + +### Example of JSON + +The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json + +`footer.default` is the fallback if the language is not supported. + +--- +Below is a visual example of a configured footer ⬇️: + +![Footer Configuration Example](./assets/footer-configurable.png) + + diff --git a/submissions/devoteam/docs/env.d/development/common b/submissions/devoteam/docs/env.d/development/common new file mode 100644 index 00000000..bf1b4553 --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/common @@ -0,0 +1,63 @@ +# Django +DJANGO_ALLOWED_HOSTS=* +DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly +DJANGO_SETTINGS_MODULE=impress.settings +DJANGO_SUPERUSER_PASSWORD=admin + +# Logging +# Set to DEBUG level for dev only +LOGGING_LEVEL_HANDLERS_CONSOLE=INFO +LOGGING_LEVEL_LOGGERS_ROOT=INFO +LOGGING_LEVEL_LOGGERS_APP=INFO + +# Python +PYTHONPATH=/app + +# impress settings + +# Mail +DJANGO_EMAIL_BRAND_NAME="La Suite Numérique" +DJANGO_EMAIL_HOST="mailcatcher" +DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png" +DJANGO_EMAIL_PORT=1025 + +# Backend url +IMPRESS_BASE_URL="http://localhost:8072" + +# Media +STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage +AWS_S3_ENDPOINT_URL=http://minio:9000 +AWS_S3_ACCESS_KEY_ID=impress +AWS_S3_SECRET_ACCESS_KEY=password +MEDIA_BASE_URL=http://localhost:8083 + +# OIDC +OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs +OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth +OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token +OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo + +OIDC_RP_CLIENT_ID=impress +OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly +OIDC_RP_SIGN_ALGO=RS256 +OIDC_RP_SCOPES="openid email" + +LOGIN_REDIRECT_URL=http://localhost:3000 +LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000 +LOGOUT_REDIRECT_URL=http://localhost:3000 + +OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"] +OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} + +# AI +AI_FEATURE_ENABLED=true +AI_BASE_URL=https://openaiendpoint.com +AI_API_KEY=password +AI_MODEL=llama + +# Collaboration +COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ +COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 +COLLABORATION_SERVER_ORIGIN=http://localhost:3000 +COLLABORATION_SERVER_SECRET=my-secret +COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ diff --git a/submissions/devoteam/docs/env.d/development/common.dist b/submissions/devoteam/docs/env.d/development/common.dist new file mode 100644 index 00000000..bf1b4553 --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/common.dist @@ -0,0 +1,63 @@ +# Django +DJANGO_ALLOWED_HOSTS=* +DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly +DJANGO_SETTINGS_MODULE=impress.settings +DJANGO_SUPERUSER_PASSWORD=admin + +# Logging +# Set to DEBUG level for dev only +LOGGING_LEVEL_HANDLERS_CONSOLE=INFO +LOGGING_LEVEL_LOGGERS_ROOT=INFO +LOGGING_LEVEL_LOGGERS_APP=INFO + +# Python +PYTHONPATH=/app + +# impress settings + +# Mail +DJANGO_EMAIL_BRAND_NAME="La Suite Numérique" +DJANGO_EMAIL_HOST="mailcatcher" +DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png" +DJANGO_EMAIL_PORT=1025 + +# Backend url +IMPRESS_BASE_URL="http://localhost:8072" + +# Media +STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage +AWS_S3_ENDPOINT_URL=http://minio:9000 +AWS_S3_ACCESS_KEY_ID=impress +AWS_S3_SECRET_ACCESS_KEY=password +MEDIA_BASE_URL=http://localhost:8083 + +# OIDC +OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs +OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth +OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token +OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo + +OIDC_RP_CLIENT_ID=impress +OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly +OIDC_RP_SIGN_ALGO=RS256 +OIDC_RP_SCOPES="openid email" + +LOGIN_REDIRECT_URL=http://localhost:3000 +LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000 +LOGOUT_REDIRECT_URL=http://localhost:3000 + +OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"] +OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} + +# AI +AI_FEATURE_ENABLED=true +AI_BASE_URL=https://openaiendpoint.com +AI_API_KEY=password +AI_MODEL=llama + +# Collaboration +COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ +COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 +COLLABORATION_SERVER_ORIGIN=http://localhost:3000 +COLLABORATION_SERVER_SECRET=my-secret +COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ diff --git a/submissions/devoteam/docs/env.d/development/common.e2e.dist b/submissions/devoteam/docs/env.d/development/common.e2e.dist new file mode 100644 index 00000000..6a67a40b --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/common.e2e.dist @@ -0,0 +1,6 @@ +# For the CI job test-e2e +BURST_THROTTLE_RATES="200/minute" +DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e +SUSTAINED_THROTTLE_RATES="200/hour" +Y_PROVIDER_API_KEY=yprovider-api-key +Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/ diff --git a/submissions/devoteam/docs/env.d/development/crowdin b/submissions/devoteam/docs/env.d/development/crowdin new file mode 100644 index 00000000..6c6a9d8a --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/crowdin @@ -0,0 +1,3 @@ +CROWDIN_PERSONAL_TOKEN=Your-Personal-Token +CROWDIN_PROJECT_ID=Your-Project-Id +CROWDIN_BASE_PATH=/app/src diff --git a/submissions/devoteam/docs/env.d/development/crowdin.dist b/submissions/devoteam/docs/env.d/development/crowdin.dist new file mode 100644 index 00000000..6c6a9d8a --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/crowdin.dist @@ -0,0 +1,3 @@ +CROWDIN_PERSONAL_TOKEN=Your-Personal-Token +CROWDIN_PROJECT_ID=Your-Project-Id +CROWDIN_BASE_PATH=/app/src diff --git a/submissions/devoteam/docs/env.d/development/kc_postgresql b/submissions/devoteam/docs/env.d/development/kc_postgresql new file mode 100644 index 00000000..505ae80d --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/kc_postgresql @@ -0,0 +1,11 @@ +# Postgresql db container configuration +POSTGRES_DB=keycloak +POSTGRES_USER=impress +POSTGRES_PASSWORD=pass + +# App database configuration +DB_HOST=kc_postgresql +DB_NAME=keycloak +DB_USER=impress +DB_PASSWORD=pass +DB_PORT=5433 \ No newline at end of file diff --git a/submissions/devoteam/docs/env.d/development/kc_postgresql.dist b/submissions/devoteam/docs/env.d/development/kc_postgresql.dist new file mode 100644 index 00000000..505ae80d --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/kc_postgresql.dist @@ -0,0 +1,11 @@ +# Postgresql db container configuration +POSTGRES_DB=keycloak +POSTGRES_USER=impress +POSTGRES_PASSWORD=pass + +# App database configuration +DB_HOST=kc_postgresql +DB_NAME=keycloak +DB_USER=impress +DB_PASSWORD=pass +DB_PORT=5433 \ No newline at end of file diff --git a/submissions/devoteam/docs/env.d/development/postgresql b/submissions/devoteam/docs/env.d/development/postgresql new file mode 100644 index 00000000..a5817257 --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/postgresql @@ -0,0 +1,11 @@ +# Postgresql db container configuration +POSTGRES_DB=impress +POSTGRES_USER=dinum +POSTGRES_PASSWORD=pass + +# App database configuration +DB_HOST=postgresql +DB_NAME=impress +DB_USER=dinum +DB_PASSWORD=pass +DB_PORT=5432 \ No newline at end of file diff --git a/submissions/devoteam/docs/env.d/development/postgresql.dist b/submissions/devoteam/docs/env.d/development/postgresql.dist new file mode 100644 index 00000000..a5817257 --- /dev/null +++ b/submissions/devoteam/docs/env.d/development/postgresql.dist @@ -0,0 +1,11 @@ +# Postgresql db container configuration +POSTGRES_DB=impress +POSTGRES_USER=dinum +POSTGRES_PASSWORD=pass + +# App database configuration +DB_HOST=postgresql +DB_NAME=impress +DB_USER=dinum +DB_PASSWORD=pass +DB_PORT=5432 \ No newline at end of file diff --git a/submissions/devoteam/docs/gitlint/gitlint_emoji.py b/submissions/devoteam/docs/gitlint/gitlint_emoji.py new file mode 100644 index 00000000..c8f7024f --- /dev/null +++ b/submissions/devoteam/docs/gitlint/gitlint_emoji.py @@ -0,0 +1,37 @@ +""" +Gitlint extra rule to validate that the message title is of the form +"() " +""" +from __future__ import unicode_literals + +import re + +import requests + +from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation + + +class GitmojiTitle(LineRule): + """ + This rule will enforce that each commit title is of the form "() " + where gitmoji is an emoji from the list defined in https://gitmoji.carloscuesta.me and + subject should be all lowercase + """ + + id = "UC1" + name = "title-should-have-gitmoji-and-scope" + target = CommitMessageTitle + + def validate(self, title, _commit): + """ + Download the list possible gitmojis from the project's github repository and check that + title contains one of them. + """ + gitmojis = requests.get( + "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json" + ).json()["gitmojis"] + emojis = [item["emoji"] for item in gitmojis] + pattern = r"^({:s})\(.*\)\s[a-zA-Z].*$".format("|".join(emojis)) + if not re.search(pattern, title): + violation_msg = 'Title does not match regex "() "' + return [RuleViolation(self.id, violation_msg, title)] diff --git a/submissions/devoteam/docs/publiccode.yml b/submissions/devoteam/docs/publiccode.yml new file mode 100644 index 00000000..0a364ae5 --- /dev/null +++ b/submissions/devoteam/docs/publiccode.yml @@ -0,0 +1,27 @@ +publiccodeYmlVersion: "2.4.0" +name: Docs +url: https://github.com/suitenumerique/docs +landingURL: https://github.com/suitenumerique/docs +creationDate: 2023-12-10 +logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png +usedBy: + - Direction interministériel du numérique (DINUM) +fundedBy: + - name: Direction interministériel du numérique (DINUM) + url: https://www.numerique.gouv.fr +roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1" +softwareType: "standalone/other" +description: + en: + shortDescription: "The open source document editor where your notes can become knowledge through live collaboration" + fr: + shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct." +legal: + license: MIT +maintenance: + type: internal + contacts: + - name: "Virgile Deville" + email: "virgile.deville@numerique.gouv.fr" + - name: "samuel.paccoud" + email: "samuel.paccoud@numerique.gouv.fr" diff --git a/submissions/devoteam/docs/renovate.json b/submissions/devoteam/docs/renovate.json new file mode 100644 index 00000000..e87c6b77 --- /dev/null +++ b/submissions/devoteam/docs/renovate.json @@ -0,0 +1,39 @@ +{ + "extends": ["github>numerique-gouv/renovate-configuration"], + "dependencyDashboard": true, + "labels": ["dependencies", "noChangeLog"], + "packageRules": [ + { + "enabled": false, + "groupName": "ignored python dependencies", + "matchManagers": ["pep621"], + "matchPackageNames": [] + }, + { + "groupName": "allowed django versions", + "matchManagers": ["pep621"], + "matchPackageNames": ["Django"], + "allowedVersions": "<5.2" + }, + { + "groupName": "allowed redis versions", + "matchManagers": ["pep621"], + "matchPackageNames": ["redis"], + "allowedVersions": "<6.0.0" + }, + { + "enabled": false, + "groupName": "ignored js dependencies", + "matchManagers": ["npm"], + "matchPackageNames": [ + "@hocuspocus/provider", + "@hocuspocus/server", + "eslint", + "fetch-mock", + "node", + "node-fetch", + "workbox-webpack-plugin" + ] + } + ] +} diff --git a/submissions/devoteam/docs/secu-audit.md b/submissions/devoteam/docs/secu-audit.md new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/.pylintrc b/submissions/devoteam/docs/src/backend/.pylintrc new file mode 100644 index 00000000..9807de7d --- /dev/null +++ b/submissions/devoteam/docs/src/backend/.pylintrc @@ -0,0 +1,472 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=migrations + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=0 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins=pylint_django,pylint.extensions.no_self_use + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=bad-inline-option, + deprecated-pragma, + django-not-configured, + file-ignored, + locally-disabled, + no-self-use, + raw-checker-failed, + suppressed-message, + useless-suppression + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=optparse.Values,sys.exit + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,responses, + Template,Contact + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=yes + +# Minimum lines number of a similarity. +# First implementations of CMS wizards have common fields we do not want to factorize for now +min-similarity-lines=35 + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + cm, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=20 + +# Maximum number of parents for a class (see R0901). +max-parents=10 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=builtins.Exception diff --git a/submissions/devoteam/docs/src/backend/MANIFEST.in b/submissions/devoteam/docs/src/backend/MANIFEST.in new file mode 100644 index 00000000..999508bf --- /dev/null +++ b/submissions/devoteam/docs/src/backend/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include README.md +recursive-include src/backend/impress *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2 diff --git a/submissions/devoteam/docs/src/backend/__init__.py b/submissions/devoteam/docs/src/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/__init__.py b/submissions/devoteam/docs/src/backend/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/admin.py b/submissions/devoteam/docs/src/backend/core/admin.py new file mode 100644 index 00000000..e096b020 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/admin.py @@ -0,0 +1,207 @@ +"""Admin classes and registrations for core app.""" + +from django.contrib import admin +from django.contrib.auth import admin as auth_admin +from django.utils.translation import gettext_lazy as _ + +from treebeard.admin import TreeAdmin +from treebeard.forms import movenodeform_factory + +from . import models + + +class TemplateAccessInline(admin.TabularInline): + """Inline admin class for template accesses.""" + + autocomplete_fields = ["user"] + model = models.TemplateAccess + extra = 0 + + +@admin.register(models.User) +class UserAdmin(auth_admin.UserAdmin): + """Admin class for the User model""" + + fieldsets = ( + ( + None, + { + "fields": ( + "id", + "admin_email", + "password", + ) + }, + ), + ( + _("Personal info"), + { + "fields": ( + "sub", + "email", + "full_name", + "short_name", + "language", + "timezone", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_device", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ), + }, + ), + (_("Important dates"), {"fields": ("created_at", "updated_at")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), + ) + inlines = (TemplateAccessInline,) + list_display = ( + "id", + "sub", + "full_name", + "admin_email", + "email", + "is_active", + "is_staff", + "is_superuser", + "is_device", + "created_at", + "updated_at", + ) + list_filter = ("is_staff", "is_superuser", "is_device", "is_active") + ordering = ( + "is_active", + "-is_superuser", + "-is_staff", + "-is_device", + "-updated_at", + "full_name", + ) + readonly_fields = ( + "id", + "sub", + "email", + "full_name", + "short_name", + "created_at", + "updated_at", + ) + search_fields = ("id", "sub", "admin_email", "email", "full_name") + + +@admin.register(models.Template) +class TemplateAdmin(admin.ModelAdmin): + """Template admin interface declaration.""" + + inlines = (TemplateAccessInline,) + + +class DocumentAccessInline(admin.TabularInline): + """Inline admin class for template accesses.""" + + autocomplete_fields = ["user"] + model = models.DocumentAccess + extra = 0 + + +@admin.register(models.Document) +class DocumentAdmin(TreeAdmin): + """Document admin interface declaration.""" + + fieldsets = ( + ( + None, + { + "fields": ( + "id", + "title", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "creator", + "link_reach", + "link_role", + ) + }, + ), + ( + _("Tree structure"), + { + "fields": ( + "path", + "depth", + "numchild", + "duplicated_from", + "attachments", + ) + }, + ), + ) + form = movenodeform_factory(models.Document) + inlines = (DocumentAccessInline,) + list_display = ( + "id", + "title", + "link_reach", + "link_role", + "created_at", + "updated_at", + ) + readonly_fields = ( + "attachments", + "creator", + "depth", + "duplicated_from", + "id", + "numchild", + "path", + ) + search_fields = ("id", "title") + + +@admin.register(models.Invitation) +class InvitationAdmin(admin.ModelAdmin): + """Admin interface to handle invitations.""" + + fields = ( + "email", + "document", + "role", + "created_at", + "issuer", + ) + readonly_fields = ( + "created_at", + "is_expired", + "issuer", + ) + list_display = ( + "email", + "document", + "created_at", + "is_expired", + ) + + def save_model(self, request, obj, form, change): + obj.issuer = request.user + obj.save() diff --git a/submissions/devoteam/docs/src/backend/core/api/__init__.py b/submissions/devoteam/docs/src/backend/core/api/__init__.py new file mode 100644 index 00000000..46f2a8b0 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/api/__init__.py @@ -0,0 +1,41 @@ +"""Impress core API endpoints""" + +from django.conf import settings +from django.core.exceptions import ValidationError + +from rest_framework import exceptions as drf_exceptions +from rest_framework import views as drf_views +from rest_framework.decorators import api_view +from rest_framework.response import Response + + +def exception_handler(exc, context): + """Handle Django ValidationError as an accepted exception. + + For the parameters, see ``exception_handler`` + This code comes from twidi's gist: + https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f + """ + if isinstance(exc, ValidationError): + detail = None + if hasattr(exc, "message_dict"): + detail = exc.message_dict + elif hasattr(exc, "message"): + detail = exc.message + elif hasattr(exc, "messages"): + detail = exc.messages + + exc = drf_exceptions.ValidationError(detail=detail) + + return drf_views.exception_handler(exc, context) + + +# pylint: disable=unused-argument +@api_view(["GET"]) +def get_frontend_configuration(request): + """Returns the frontend configuration dict as configured in settings.""" + frontend_configuration = { + "LANGUAGE_CODE": settings.LANGUAGE_CODE, + } + frontend_configuration.update(settings.FRONTEND_CONFIGURATION) + return Response(frontend_configuration) diff --git a/submissions/devoteam/docs/src/backend/core/api/fields.py b/submissions/devoteam/docs/src/backend/core/api/fields.py new file mode 100644 index 00000000..11256224 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/api/fields.py @@ -0,0 +1,25 @@ +"""A JSONField for DRF to handle serialization/deserialization.""" + +import json + +from rest_framework import serializers + + +class JSONField(serializers.Field): + """ + A custom field for handling JSON data. + """ + + def to_representation(self, value): + """ + Convert the JSON string to a Python dictionary for serialization. + """ + return value + + def to_internal_value(self, data): + """ + Convert the Python dictionary to a JSON string for deserialization. + """ + if data is None: + return None + return json.dumps(data) diff --git a/submissions/devoteam/docs/src/backend/core/api/filters.py b/submissions/devoteam/docs/src/backend/core/api/filters.py new file mode 100644 index 00000000..5b0721fb --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/api/filters.py @@ -0,0 +1,108 @@ +"""API filters for Impress' core application.""" + +import unicodedata + +from django.utils.translation import gettext_lazy as _ + +import django_filters + +from core import models + + +def remove_accents(value): + """Remove accents from a string (vélo -> velo).""" + return "".join( + c + for c in unicodedata.normalize("NFD", value) + if unicodedata.category(c) != "Mn" + ) + + +class AccentInsensitiveCharFilter(django_filters.CharFilter): + """ + A custom CharFilter that filters on the accent-insensitive value searched. + """ + + def filter(self, qs, value): + """ + Apply the filter to the queryset using the unaccented version of the field. + + Args: + qs: The queryset to filter. + value: The value to search for in the unaccented field. + Returns: + A filtered queryset. + """ + if value: + value = remove_accents(value) + return super().filter(qs, value) + + +class DocumentFilter(django_filters.FilterSet): + """ + Custom filter for filtering documents on title (accent and case insensitive). + """ + + title = AccentInsensitiveCharFilter( + field_name="title", lookup_expr="unaccent__icontains", label=_("Title") + ) + + class Meta: + model = models.Document + fields = ["title"] + + +class ListDocumentFilter(DocumentFilter): + """ + Custom filter for filtering documents. + """ + + is_creator_me = django_filters.BooleanFilter( + method="filter_is_creator_me", label=_("Creator is me") + ) + is_favorite = django_filters.BooleanFilter( + method="filter_is_favorite", label=_("Favorite") + ) + + class Meta: + model = models.Document + fields = ["is_creator_me", "is_favorite", "title"] + + # pylint: disable=unused-argument + def filter_is_creator_me(self, queryset, name, value): + """ + Filter documents based on the `creator` being the current user. + + Example: + - /api/v1.0/documents/?is_creator_me=true + → Filters documents created by the logged-in user + - /api/v1.0/documents/?is_creator_me=false + → Filters documents created by other users + """ + user = self.request.user + + if not user.is_authenticated: + return queryset + + if value: + return queryset.filter(creator=user) + + return queryset.exclude(creator=user) + + # pylint: disable=unused-argument + def filter_is_favorite(self, queryset, name, value): + """ + Filter documents based on whether they are marked as favorite by the current user. + + Example: + - /api/v1.0/documents/?is_favorite=true + → Filters documents marked as favorite by the logged-in user + - /api/v1.0/documents/?is_favorite=false + → Filters documents not marked as favorite by the logged-in user + """ + user = self.request.user + + if not user.is_authenticated: + return queryset + + return queryset.filter(is_favorite=bool(value)) diff --git a/submissions/devoteam/docs/src/backend/core/api/permissions.py b/submissions/devoteam/docs/src/backend/core/api/permissions.py new file mode 100644 index 00000000..43a0465f --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/api/permissions.py @@ -0,0 +1,136 @@ +"""Permission handlers for the impress core app.""" + +from django.core import exceptions +from django.db.models import Q +from django.http import Http404 + +from rest_framework import permissions + +from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff + +ACTION_FOR_METHOD_TO_PERMISSION = { + "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}, + "children": {"GET": "children_list", "POST": "children_create"}, +} + + +class IsAuthenticated(permissions.BasePermission): + """ + Allows access only to authenticated users. Alternative method checking the presence + of the auth token to avoid hitting the database. + """ + + def has_permission(self, request, view): + return bool(request.auth) or request.user.is_authenticated + + +class IsAuthenticatedOrSafe(IsAuthenticated): + """Allows access to authenticated users (or anonymous users but only on safe methods).""" + + def has_permission(self, request, view): + if request.method in permissions.SAFE_METHODS: + return True + return super().has_permission(request, view) + + +class IsSelf(IsAuthenticated): + """ + Allows access only to authenticated users. Alternative method checking the presence + of the auth token to avoid hitting the database. + """ + + def has_object_permission(self, request, view, obj): + """Write permissions are only allowed to the user itself.""" + return obj == request.user + + +class IsOwnedOrPublic(IsAuthenticated): + """ + Allows access to authenticated users only for objects that are owned or not related + to any user via the "owner" field. + """ + + def has_object_permission(self, request, view, obj): + """Unsafe permissions are only allowed for the owner of the object.""" + if obj.owner == request.user: + return True + + if request.method in permissions.SAFE_METHODS and obj.owner is None: + return True + + try: + return obj.user == request.user + except exceptions.ObjectDoesNotExist: + return False + + +class CanCreateInvitationPermission(permissions.BasePermission): + """ + Custom permission class to handle permission checks for managing invitations. + """ + + def has_permission(self, request, view): + user = request.user + + # Ensure the user is authenticated + if not (bool(request.auth) or request.user.is_authenticated): + return False + + # Apply permission checks only for creation (POST requests) + if view.action != "create": + return True + + # Check if resource_id is passed in the context + try: + document_id = view.kwargs["resource_id"] + except KeyError as exc: + raise exceptions.ValidationError( + "You must set a document ID in kwargs to manage document invitations." + ) from exc + + # Check if the user has access to manage invitations (Owner/Admin roles) + return DocumentAccess.objects.filter( + Q(user=user) | Q(team__in=user.teams), + document=document_id, + role__in=[RoleChoices.OWNER, RoleChoices.ADMIN], + ).exists() + + +class AccessPermission(permissions.BasePermission): + """Permission class for access objects.""" + + def has_permission(self, request, view): + return request.user.is_authenticated or view.action != "create" + + def has_object_permission(self, request, view, obj): + """Check permission for a given object.""" + abilities = obj.get_abilities(request.user) + action = view.action + try: + action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method] + except KeyError: + pass + return abilities.get(action, False) + + +class DocumentAccessPermission(AccessPermission): + """Subclass to handle soft deletion specificities.""" + + def has_object_permission(self, request, view, obj): + """ + Return a 404 on deleted documents + - for which the trashbin cutoff is past + - for which the current user is not owner of the document or one of its ancestors + """ + if ( + deleted_at := obj.ancestors_deleted_at + ) and deleted_at < get_trashbin_cutoff(): + raise Http404 + + # Compute permission first to ensure the "user_roles" attribute is set + has_permission = super().has_object_permission(request, view, obj) + + if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles: + raise Http404 + + return has_permission diff --git a/submissions/devoteam/docs/src/backend/core/api/serializers.py b/submissions/devoteam/docs/src/backend/core/api/serializers.py new file mode 100644 index 00000000..e86288bb --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/api/serializers.py @@ -0,0 +1,737 @@ +"""Client serializers for the impress core app.""" + +import binascii +import mimetypes +from base64 import b64decode + +from django.conf import settings +from django.db.models import Q +from django.utils.functional import lazy +from django.utils.translation import gettext_lazy as _ + +import magic +from rest_framework import exceptions, serializers + +from core import enums, models, utils +from core.services.ai_services import AI_ACTIONS +from core.services.converter_services import ( + ConversionError, + YdocConverter, +) + + +class UserSerializer(serializers.ModelSerializer): + """Serialize users.""" + + class Meta: + model = models.User + fields = ["id", "email", "full_name", "short_name", "language"] + read_only_fields = ["id", "email", "full_name", "short_name"] + + +class UserLightSerializer(UserSerializer): + """Serialize users with limited fields.""" + + id = serializers.SerializerMethodField(read_only=True) + email = serializers.SerializerMethodField(read_only=True) + + def get_id(self, _user): + """Return always None. Here to have the same fields than in UserSerializer.""" + return None + + def get_email(self, _user): + """Return always None. Here to have the same fields than in UserSerializer.""" + return None + + class Meta: + model = models.User + fields = ["id", "email", "full_name", "short_name"] + read_only_fields = ["id", "email", "full_name", "short_name"] + + +class BaseAccessSerializer(serializers.ModelSerializer): + """Serialize template accesses.""" + + abilities = serializers.SerializerMethodField(read_only=True) + + def update(self, instance, validated_data): + """Make "user" field is readonly but only on update.""" + validated_data.pop("user", None) + return super().update(instance, validated_data) + + def get_abilities(self, access) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return access.get_abilities(request.user) + return {} + + def validate(self, attrs): + """ + Check access rights specific to writing (create/update) + """ + request = self.context.get("request") + user = getattr(request, "user", None) + role = attrs.get("role") + + # Update + if self.instance: + can_set_role_to = self.instance.get_abilities(user)["set_role_to"] + + if role and role not in can_set_role_to: + message = ( + f"You are only allowed to set role to {', '.join(can_set_role_to)}" + if can_set_role_to + else "You are not allowed to set this role for this template." + ) + raise exceptions.PermissionDenied(message) + + # Create + else: + try: + resource_id = self.context["resource_id"] + except KeyError as exc: + raise exceptions.ValidationError( + "You must set a resource ID in kwargs to create a new access." + ) from exc + + if not self.Meta.model.objects.filter( # pylint: disable=no-member + Q(user=user) | Q(team__in=user.teams), + role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN], + **{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member + ).exists(): + raise exceptions.PermissionDenied( + "You are not allowed to manage accesses for this resource." + ) + + if ( + role == models.RoleChoices.OWNER + and not self.Meta.model.objects.filter( # pylint: disable=no-member + Q(user=user) | Q(team__in=user.teams), + role=models.RoleChoices.OWNER, + **{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member + ).exists() + ): + raise exceptions.PermissionDenied( + "Only owners of a resource can assign other users as owners." + ) + + # pylint: disable=no-member + attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"] + return attrs + + +class DocumentAccessSerializer(BaseAccessSerializer): + """Serialize document accesses.""" + + user_id = serializers.PrimaryKeyRelatedField( + queryset=models.User.objects.all(), + write_only=True, + source="user", + required=False, + allow_null=True, + ) + user = UserSerializer(read_only=True) + + class Meta: + model = models.DocumentAccess + resource_field_name = "document" + fields = ["id", "user", "user_id", "team", "role", "abilities"] + read_only_fields = ["id", "abilities"] + + +class DocumentAccessLightSerializer(DocumentAccessSerializer): + """Serialize document accesses with limited fields.""" + + user = UserLightSerializer(read_only=True) + + class Meta: + model = models.DocumentAccess + fields = ["id", "user", "team", "role", "abilities"] + read_only_fields = ["id", "team", "role", "abilities"] + + +class TemplateAccessSerializer(BaseAccessSerializer): + """Serialize template accesses.""" + + class Meta: + model = models.TemplateAccess + resource_field_name = "template" + fields = ["id", "user", "team", "role", "abilities"] + read_only_fields = ["id", "abilities"] + + +class ListDocumentSerializer(serializers.ModelSerializer): + """Serialize documents with limited fields for display in lists.""" + + is_favorite = serializers.BooleanField(read_only=True) + nb_accesses_ancestors = serializers.IntegerField(read_only=True) + nb_accesses_direct = serializers.IntegerField(read_only=True) + user_roles = serializers.SerializerMethodField(read_only=True) + abilities = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.Document + fields = [ + "id", + "abilities", + "created_at", + "creator", + "depth", + "excerpt", + "is_favorite", + "link_role", + "link_reach", + "nb_accesses_ancestors", + "nb_accesses_direct", + "numchild", + "path", + "title", + "updated_at", + "user_roles", + ] + read_only_fields = [ + "id", + "abilities", + "created_at", + "creator", + "depth", + "excerpt", + "is_favorite", + "link_role", + "link_reach", + "nb_accesses_ancestors", + "nb_accesses_direct", + "numchild", + "path", + "updated_at", + "user_roles", + ] + + def get_abilities(self, document) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + + if request: + paths_links_mapping = self.context.get("paths_links_mapping", None) + # Retrieve ancestor links from paths_links_mapping (if provided) + ancestors_links = ( + paths_links_mapping.get(document.path[: -document.steplen]) + if paths_links_mapping + else None + ) + return document.get_abilities(request.user, ancestors_links=ancestors_links) + + return {} + + def get_user_roles(self, document): + """ + Return roles of the logged-in user for the current document, + taking into account ancestors. + """ + request = self.context.get("request") + if request: + return document.get_roles(request.user) + return [] + + +class DocumentSerializer(ListDocumentSerializer): + """Serialize documents with all fields for display in detail views.""" + + content = serializers.CharField(required=False) + + class Meta: + model = models.Document + fields = [ + "id", + "abilities", + "content", + "created_at", + "creator", + "depth", + "excerpt", + "is_favorite", + "link_role", + "link_reach", + "nb_accesses_ancestors", + "nb_accesses_direct", + "numchild", + "path", + "title", + "updated_at", + "user_roles", + ] + read_only_fields = [ + "id", + "abilities", + "created_at", + "creator", + "depth", + "is_favorite", + "link_role", + "link_reach", + "nb_accesses_ancestors", + "nb_accesses_direct", + "numchild", + "path", + "updated_at", + "user_roles", + ] + + def get_fields(self): + """Dynamically make `id` read-only on PUT requests but writable on POST requests.""" + fields = super().get_fields() + + request = self.context.get("request") + if request and request.method == "POST": + fields["id"].read_only = False + + return fields + + def validate_id(self, value): + """Ensure the provided ID does not already exist when creating a new document.""" + request = self.context.get("request") + + # Only check this on POST (creation) + if request and request.method == "POST": + if models.Document.objects.filter(id=value).exists(): + raise serializers.ValidationError( + "A document with this ID already exists. You cannot override it." + ) + + return value + + def validate_content(self, value): + """Validate the content field.""" + if not value: + return None + + try: + b64decode(value, validate=True) + except binascii.Error as err: + raise serializers.ValidationError("Invalid base64 content.") from err + + return value + + def save(self, **kwargs): + """ + Process the content field to extract attachment keys and update the document's + "attachments" field for access control. + """ + content = self.validated_data.get("content", "") + extracted_attachments = set(utils.extract_attachments(content)) + + existing_attachments = ( + set(self.instance.attachments or []) if self.instance else set() + ) + new_attachments = extracted_attachments - existing_attachments + + if new_attachments: + attachments_documents = ( + models.Document.objects.filter( + attachments__overlap=list(new_attachments) + ) + .only("path", "attachments") + .order_by("path") + ) + + user = self.context["request"].user + readable_per_se_paths = ( + models.Document.objects.readable_per_se(user) + .order_by("path") + .values_list("path", flat=True) + ) + readable_attachments_paths = utils.filter_descendants( + [doc.path for doc in attachments_documents], + readable_per_se_paths, + skip_sorting=True, + ) + + readable_attachments = set() + for document in attachments_documents: + if document.path not in readable_attachments_paths: + continue + readable_attachments.update(set(document.attachments) & new_attachments) + + # Update attachments with readable keys + self.validated_data["attachments"] = list( + existing_attachments | readable_attachments + ) + + return super().save(**kwargs) + + +class ServerCreateDocumentSerializer(serializers.Serializer): + """ + Serializer for creating a document from a server-to-server request. + + Expects 'content' as a markdown string, which is converted to our internal format + via a Node.js microservice. The conversion is handled automatically, so third parties + only need to provide markdown. + + Both "sub" and "email" are required because the external app calling doesn't know + if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the + submitted "email" field and use the email address set on the user account in our database + """ + + # Document + title = serializers.CharField(required=True) + content = serializers.CharField(required=True) + # User + sub = serializers.CharField( + required=True, validators=[models.User.sub_validator], max_length=255 + ) + email = serializers.EmailField(required=True) + language = serializers.ChoiceField( + required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)() + ) + # Invitation + message = serializers.CharField(required=False) + subject = serializers.CharField(required=False) + + def create(self, validated_data): + """Create the document and associate it with the user or send an invitation.""" + language = validated_data.get("language", settings.LANGUAGE_CODE) + + # Get the user on its sub (unique identifier). Default on email if allowed in settings + email = validated_data["email"] + + try: + user = models.User.objects.get_user_by_sub_or_email( + validated_data["sub"], email + ) + except models.DuplicateEmailError as err: + raise serializers.ValidationError({"email": [err.message]}) from err + + if user: + email = user.email + language = user.language or language + + try: + document_content = YdocConverter().convert_markdown( + validated_data["content"] + ) + except ConversionError as err: + raise serializers.ValidationError( + {"content": ["Could not convert content"]} + ) from err + + document = models.Document.add_root( + title=validated_data["title"], + content=document_content, + creator=user, + ) + + if user: + # Associate the document with the pre-existing user + models.DocumentAccess.objects.create( + document=document, + role=models.RoleChoices.OWNER, + user=user, + ) + else: + # The user doesn't exist in our database: we need to invite him/her + models.Invitation.objects.create( + document=document, + email=email, + role=models.RoleChoices.OWNER, + ) + + self._send_email_notification(document, validated_data, email, language) + return document + + def _send_email_notification(self, document, validated_data, email, language): + """Notify the user about the newly created document.""" + subject = validated_data.get("subject") or _( + "A new document was created on your behalf!" + ) + context = { + "message": validated_data.get("message") + or _("You have been granted ownership of a new document:"), + "title": subject, + } + document.send_email(subject, [email], context, language) + + def update(self, instance, validated_data): + """ + This serializer does not support updates. + """ + raise NotImplementedError("Update is not supported for this serializer.") + + +class LinkDocumentSerializer(serializers.ModelSerializer): + """ + Serialize link configuration for documents. + We expose it separately from document in order to simplify and secure access control. + """ + + class Meta: + model = models.Document + fields = [ + "link_role", + "link_reach", + ] + + +class DocumentDuplicationSerializer(serializers.Serializer): + """ + Serializer for duplicating a document. + Allows specifying whether to keep access permissions. + """ + + with_accesses = serializers.BooleanField(default=False) + + def create(self, validated_data): + """ + This serializer is not intended to create objects. + """ + raise NotImplementedError("This serializer does not support creation.") + + def update(self, instance, validated_data): + """ + This serializer is not intended to update objects. + """ + raise NotImplementedError("This serializer does not support updating.") + + +# Suppress the warning about not implementing `create` and `update` methods +# since we don't use a model and only rely on the serializer for validation +# pylint: disable=abstract-method +class FileUploadSerializer(serializers.Serializer): + """Receive file upload requests.""" + + file = serializers.FileField() + + def validate_file(self, file): + """Add file size and type constraints as defined in settings.""" + # Validate file size + if file.size > settings.DOCUMENT_IMAGE_MAX_SIZE: + max_size = settings.DOCUMENT_IMAGE_MAX_SIZE // (1024 * 1024) + raise serializers.ValidationError( + f"File size exceeds the maximum limit of {max_size:d} MB." + ) + + extension = file.name.rpartition(".")[-1] if "." in file.name else None + + # Read the first few bytes to determine the MIME type accurately + mime = magic.Magic(mime=True) + magic_mime_type = mime.from_buffer(file.read(1024)) + file.seek(0) # Reset file pointer to the beginning after reading + + self.context["is_unsafe"] = ( + magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES + ) + + extension_mime_type, _ = mimetypes.guess_type(file.name) + + # Try guessing a coherent extension from the mimetype + if extension_mime_type != magic_mime_type: + self.context["is_unsafe"] = True + + guessed_ext = mimetypes.guess_extension(magic_mime_type) + # Missing extensions or extensions longer than 5 characters (it's as long as an extension + # can be) are replaced by the extension we eventually guessed from mimetype. + if (extension is None or len(extension) > 5) and guessed_ext: + extension = guessed_ext[1:] + + if extension is None: + raise serializers.ValidationError("Could not determine file extension.") + + self.context["expected_extension"] = extension + self.context["content_type"] = magic_mime_type + self.context["file_name"] = file.name + + return file + + def validate(self, attrs): + """Override validate to add the computed extension to validated_data.""" + attrs["expected_extension"] = self.context["expected_extension"] + attrs["is_unsafe"] = self.context["is_unsafe"] + attrs["content_type"] = self.context["content_type"] + attrs["file_name"] = self.context["file_name"] + return attrs + + +class TemplateSerializer(serializers.ModelSerializer): + """Serialize templates.""" + + abilities = serializers.SerializerMethodField(read_only=True) + accesses = TemplateAccessSerializer(many=True, read_only=True) + + class Meta: + model = models.Template + fields = [ + "id", + "title", + "accesses", + "abilities", + "css", + "code", + "is_public", + ] + read_only_fields = ["id", "accesses", "abilities"] + + def get_abilities(self, document) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return document.get_abilities(request.user) + return {} + + +# pylint: disable=abstract-method +class DocumentGenerationSerializer(serializers.Serializer): + """Serializer to receive a request to generate a document on a template.""" + + body = serializers.CharField(label=_("Body")) + body_type = serializers.ChoiceField( + choices=["html", "markdown"], + label=_("Body type"), + required=False, + default="html", + ) + format = serializers.ChoiceField( + choices=["pdf", "docx"], + label=_("Format"), + required=False, + default="pdf", + ) + + +class InvitationSerializer(serializers.ModelSerializer): + """Serialize invitations.""" + + abilities = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.Invitation + fields = [ + "id", + "abilities", + "created_at", + "email", + "document", + "role", + "issuer", + "is_expired", + ] + read_only_fields = [ + "id", + "abilities", + "created_at", + "document", + "issuer", + "is_expired", + ] + + def get_abilities(self, invitation) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return invitation.get_abilities(request.user) + return {} + + def validate(self, attrs): + """Validate invitation data.""" + request = self.context.get("request") + user = getattr(request, "user", None) + + attrs["document_id"] = self.context["resource_id"] + + # Only set the issuer if the instance is being created + if self.instance is None: + attrs["issuer"] = user + + return attrs + + def validate_role(self, role): + """Custom validation for the role field.""" + request = self.context.get("request") + user = getattr(request, "user", None) + document_id = self.context["resource_id"] + + # If the role is OWNER, check if the user has OWNER access + if role == models.RoleChoices.OWNER: + if not models.DocumentAccess.objects.filter( + Q(user=user) | Q(team__in=user.teams), + document=document_id, + role=models.RoleChoices.OWNER, + ).exists(): + raise serializers.ValidationError( + "Only owners of a document can invite other users as owners." + ) + + return role + + +class VersionFilterSerializer(serializers.Serializer): + """Validate version filters applied to the list endpoint.""" + + version_id = serializers.CharField(required=False, allow_blank=True) + page_size = serializers.IntegerField( + required=False, min_value=1, max_value=50, default=20 + ) + + +class AITransformSerializer(serializers.Serializer): + """Serializer for AI transform requests.""" + + action = serializers.ChoiceField(choices=AI_ACTIONS, required=True) + text = serializers.CharField(required=True) + + def validate_text(self, value): + """Ensure the text field is not empty.""" + + if len(value.strip()) == 0: + raise serializers.ValidationError("Text field cannot be empty.") + return value + + +class AITranslateSerializer(serializers.Serializer): + """Serializer for AI translate requests.""" + + language = serializers.ChoiceField( + choices=tuple(enums.ALL_LANGUAGES.items()), required=True + ) + text = serializers.CharField(required=True) + + def validate_text(self, value): + """Ensure the text field is not empty.""" + + if len(value.strip()) == 0: + raise serializers.ValidationError("Text field cannot be empty.") + return value + + +class MoveDocumentSerializer(serializers.Serializer): + """ + Serializer for validating input data to move a document within the tree structure. + + Fields: + - target_document_id (UUIDField): The ID of the target parent document where the + document should be moved. This field is required and must be a valid UUID. + - position (ChoiceField): Specifies the position of the document in relation to + the target parent's children. + Choices: + - "first-child": Place the document as the first child of the target parent. + - "last-child": Place the document as the last child of the target parent (default). + - "left": Place the document as the left sibling of the target parent. + - "right": Place the document as the right sibling of the target parent. + + Example: + Input payload for moving a document: + { + "target_document_id": "123e4567-e89b-12d3-a456-426614174000", + "position": "first-child" + } + + Notes: + - The `target_document_id` is mandatory. + - The `position` defaults to "last-child" if not provided. + """ + + target_document_id = serializers.UUIDField(required=True) + position = serializers.ChoiceField( + choices=enums.MoveNodePositionChoices.choices, + default=enums.MoveNodePositionChoices.LAST_CHILD, + ) diff --git a/submissions/devoteam/docs/src/backend/core/api/utils.py b/submissions/devoteam/docs/src/backend/core/api/utils.py new file mode 100644 index 00000000..98dc6548 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/api/utils.py @@ -0,0 +1,181 @@ +"""Util to generate S3 authorization headers for object storage access control""" + +import time +from abc import ABC, abstractmethod + +from django.conf import settings +from django.core.cache import cache +from django.core.files.storage import default_storage + +import botocore +from rest_framework.throttling import BaseThrottle + + +def nest_tree(flat_list, steplen): + """ + Convert a flat list of serialized documents into a nested tree making advantage + of the`path` field and its step length. + """ + node_dict = {} + roots = [] + + # Sort the flat list by path to ensure parent nodes are processed first + flat_list.sort(key=lambda x: x["path"]) + + for node in flat_list: + node["children"] = [] # Initialize children list + node_dict[node["path"]] = node + + # Determine parent path + parent_path = node["path"][:-steplen] + + if parent_path in node_dict: + node_dict[parent_path]["children"].append(node) + else: + roots.append(node) # Collect root nodes + + if len(roots) > 1: + raise ValueError("More than one root element detected.") + + return roots[0] if roots else None + + +def filter_root_paths(paths, skip_sorting=False): + """ + Filters root paths from a list of paths representing a tree structure. + A root path is defined as a path that is not a prefix of any other path. + + Args: + paths (list of str): The list of paths. + + Returns: + list of str: The filtered list of root paths. + """ + if not skip_sorting: + paths.sort() + + root_paths = [] + for path in paths: + # If the current path is not a prefix of the last added root path, add it + if not root_paths or not path.startswith(root_paths[-1]): + root_paths.append(path) + + return root_paths + + +def generate_s3_authorization_headers(key): + """ + Generate authorization headers for an s3 object. + These headers can be used as an alternative to signed urls with many benefits: + - the urls of our files never expire and can be stored in our documents' content + - we don't leak authorized urls that could be shared (file access can only be done + with cookies) + - access control is truly realtime + - the object storage service does not need to be exposed on internet + """ + url = default_storage.unsigned_connection.meta.client.generate_presigned_url( + "get_object", + ExpiresIn=0, + Params={"Bucket": default_storage.bucket_name, "Key": key}, + ) + request = botocore.awsrequest.AWSRequest(method="get", url=url) + + s3_client = default_storage.connection.meta.client + # pylint: disable=protected-access + credentials = s3_client._request_signer._credentials # noqa: SLF001 + frozen_credentials = credentials.get_frozen_credentials() + region = s3_client.meta.region_name + auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region) + auth.add_auth(request) + + return request + + +class AIBaseRateThrottle(BaseThrottle, ABC): + """Base throttle class for AI-related rate limiting with backoff.""" + + def __init__(self, rates): + """Initialize instance attributes with configurable rates.""" + super().__init__() + self.rates = rates + self.cache_key = None + self.recent_requests_minute = 0 + self.recent_requests_hour = 0 + self.recent_requests_day = 0 + + @abstractmethod + def get_cache_key(self, request, view): + """Abstract method to generate cache key for throttling.""" + + def allow_request(self, request, view): + """Check if the request is allowed based on rate limits.""" + self.cache_key = self.get_cache_key(request, view) + if not self.cache_key: + return True # Allow if no cache key is generated + + now = time.time() + history = cache.get(self.cache_key, []) + # Keep requests within the last 24 hours + history = [req for req in history if req > now - 86400] + + # Calculate recent requests + self.recent_requests_minute = len([req for req in history if req > now - 60]) + self.recent_requests_hour = len([req for req in history if req > now - 3600]) + self.recent_requests_day = len(history) + + # Check rate limits + if self.recent_requests_minute >= self.rates["minute"]: + return False + if self.recent_requests_hour >= self.rates["hour"]: + return False + if self.recent_requests_day >= self.rates["day"]: + return False + + # Log the request + history.append(now) + cache.set(self.cache_key, history, timeout=86400) + return True + + def wait(self): + """Implement a backoff strategy by increasing wait time based on limits hit.""" + if self.recent_requests_day >= self.rates["day"]: + return 86400 + if self.recent_requests_hour >= self.rates["hour"]: + return 3600 + if self.recent_requests_minute >= self.rates["minute"]: + return 60 + return None + + +class AIDocumentRateThrottle(AIBaseRateThrottle): + """Throttle for limiting AI requests per document with backoff.""" + + def __init__(self, *args, **kwargs): + super().__init__(settings.AI_DOCUMENT_RATE_THROTTLE_RATES) + + def get_cache_key(self, request, view): + """Include document ID in the cache key.""" + document_id = view.kwargs["pk"] + return f"document_{document_id}_throttle_ai" + + +class AIUserRateThrottle(AIBaseRateThrottle): + """Throttle that limits requests per user or IP with backoff and rate limits.""" + + def __init__(self, *args, **kwargs): + super().__init__(settings.AI_USER_RATE_THROTTLE_RATES) + + def get_cache_key(self, request, view=None): + """Generate a cache key based on the user ID or IP for anonymous users.""" + if request.user.is_authenticated: + return f"user_{request.user.id!s}_throttle_ai" + return f"anonymous_{self.get_ident(request)}_throttle_ai" + + def get_ident(self, request): + """Return the request IP address.""" + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + return ( + x_forwarded_for.split(",")[0] + if x_forwarded_for + else request.META.get("REMOTE_ADDR") + ) diff --git a/submissions/devoteam/docs/src/backend/core/api/viewsets.py b/submissions/devoteam/docs/src/backend/core/api/viewsets.py new file mode 100644 index 00000000..cf993070 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/api/viewsets.py @@ -0,0 +1,1842 @@ +"""API endpoints""" +# pylint: disable=too-many-lines + +import json +import logging +import uuid +from urllib.parse import unquote, urlencode, urlparse + +from django.conf import settings +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.search import TrigramSimilarity +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.core.files.storage import default_storage +from django.db import connection, transaction +from django.db import models as db +from django.db.models.expressions import RawSQL +from django.db.models.functions import Left, Length +from django.http import Http404, StreamingHttpResponse +from django.urls import reverse +from django.utils.text import capfirst, slugify +from django.utils.translation import gettext_lazy as _ + +import requests +import rest_framework as drf +from botocore.exceptions import ClientError +from lasuite.malware_detection import malware_detection +from rest_framework import filters, status, viewsets +from rest_framework import response as drf_response +from rest_framework.permissions import AllowAny +from rest_framework.throttling import UserRateThrottle + +from core import authentication, enums, models +from core.services.ai_services import AIService +from core.services.collaboration_services import CollaborationService +from core.utils import extract_attachments, filter_descendants + +from . import permissions, serializers, utils +from .filters import DocumentFilter, ListDocumentFilter + +logger = logging.getLogger(__name__) + +# pylint: disable=too-many-ancestors + + +class NestedGenericViewSet(viewsets.GenericViewSet): + """ + A generic Viewset aims to be used in a nested route context. + e.g: `/api/v1.0/resource_1//resource_2//` + + It allows to define all url kwargs and lookup fields to perform the lookup. + """ + + lookup_fields: list[str] = ["pk"] + lookup_url_kwargs: list[str] = [] + + def __getattribute__(self, item): + """ + This method is overridden to allow to get the last lookup field or lookup url kwarg + when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful + to keep compatibility with all methods used by the parent class `GenericViewSet`. + """ + if item in ["lookup_field", "lookup_url_kwarg"]: + return getattr(self, item + "s", [None])[-1] + + return super().__getattribute__(item) + + def get_queryset(self): + """ + Get the list of items for this view. + + `lookup_fields` attribute is enumerated here to perform the nested lookup. + """ + queryset = super().get_queryset() + + # The last lookup field is removed to perform the nested lookup as it corresponds + # to the object pk, it is used within get_object method. + lookup_url_kwargs = ( + self.lookup_url_kwargs[:-1] + if self.lookup_url_kwargs + else self.lookup_fields[:-1] + ) + + filter_kwargs = {} + for index, lookup_url_kwarg in enumerate(lookup_url_kwargs): + if lookup_url_kwarg not in self.kwargs: + raise KeyError( + f"Expected view {self.__class__.__name__} to be called with a URL " + f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or ' + "set the `.lookup_fields` attribute on the view correctly." + ) + + filter_kwargs.update( + {self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]} + ) + + return queryset.filter(**filter_kwargs) + + +class SerializerPerActionMixin: + """ + A mixin to allow to define serializer classes for each action. + + This mixin is useful to avoid to define a serializer class for each action in the + `get_serializer_class` method. + + Example: + ``` + class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet): + serializer_class = MySerializer + list_serializer_class = MyListSerializer + retrieve_serializer_class = MyRetrieveSerializer + ``` + """ + + def get_serializer_class(self): + """ + Return the serializer class to use depending on the action. + """ + if serializer_class := getattr(self, f"{self.action}_serializer_class", None): + return serializer_class + return super().get_serializer_class() + + +class Pagination(drf.pagination.PageNumberPagination): + """Pagination to display no more than 100 objects per page sorted by creation date.""" + + ordering = "-created_on" + max_page_size = 200 + page_size_query_param = "page_size" + + +class UserListThrottleBurst(UserRateThrottle): + """Throttle for the user list endpoint.""" + + scope = "user_list_burst" + + +class UserListThrottleSustained(UserRateThrottle): + """Throttle for the user list endpoint.""" + + scope = "user_list_sustained" + + +class UserViewSet( + drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin +): + """User ViewSet""" + + permission_classes = [permissions.IsSelf] + queryset = models.User.objects.filter(is_active=True) + serializer_class = serializers.UserSerializer + pagination_class = None + throttle_classes = [] + + def get_throttles(self): + self.throttle_classes = [] + if self.action == "list": + self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained] + + return super().get_throttles() + + def get_queryset(self): + """ + Limit listed users by querying the email field with a trigram similarity + search if a query is provided. + Limit listed users by excluding users already in the document if a document_id + is provided. + """ + queryset = self.queryset + + if self.action != "list": + return queryset + + # Exclude all users already in the given document + if document_id := self.request.query_params.get("document_id", ""): + queryset = queryset.exclude(documentaccess__document_id=document_id) + + if not (query := self.request.query_params.get("q", "")) or len(query) < 5: + return queryset.none() + + # For emails, match emails by Levenstein distance to prevent typing errors + if "@" in query: + return ( + queryset.annotate( + distance=RawSQL("levenshtein(email::text, %s::text)", (query,)) + ) + .filter(distance__lte=3) + .order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT] + ) + + # Use trigram similarity for non-email-like queries + # For performance reasons we filter first by similarity, which relies on an + # index, then only calculate precise similarity scores for sorting purposes + return ( + queryset.filter(email__trigram_word_similar=query) + .annotate(similarity=TrigramSimilarity("email", query)) + .filter(similarity__gt=0.2) + .order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT] + ) + + @drf.decorators.action( + detail=False, + methods=["get"], + url_name="me", + url_path="me", + permission_classes=[permissions.IsAuthenticated], + ) + def get_me(self, request): + """ + Return information on currently logged user + """ + context = {"request": request} + return drf.response.Response( + self.serializer_class(request.user, context=context).data + ) + + +class ResourceAccessViewsetMixin: + """Mixin with methods common to all access viewsets.""" + + def get_permissions(self): + """User only needs to be authenticated to list resource accesses""" + if self.action == "list": + permission_classes = [permissions.IsAuthenticated] + else: + return super().get_permissions() + + return [permission() for permission in permission_classes] + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["resource_id"] = self.kwargs["resource_id"] + return context + + def get_queryset(self): + """Return the queryset according to the action.""" + queryset = super().get_queryset() + queryset = queryset.filter( + **{self.resource_field_name: self.kwargs["resource_id"]} + ) + + if self.action == "list": + user = self.request.user + teams = user.teams + user_roles_query = ( + queryset.filter( + db.Q(user=user) | db.Q(team__in=teams), + **{self.resource_field_name: self.kwargs["resource_id"]}, + ) + .values(self.resource_field_name) + .annotate(roles_array=ArrayAgg("role")) + .values("roles_array") + ) + + # Limit to resource access instances related to a resource THAT also has + # a resource access + # instance for the logged-in user (we don't want to list only the resource + # access instances pointing to the logged-in user) + queryset = ( + queryset.filter( + db.Q(**{f"{self.resource_field_name}__accesses__user": user}) + | db.Q( + **{f"{self.resource_field_name}__accesses__team__in": teams} + ), + **{self.resource_field_name: self.kwargs["resource_id"]}, + ) + .annotate(user_roles=db.Subquery(user_roles_query)) + .distinct() + ) + return queryset + + def destroy(self, request, *args, **kwargs): + """Forbid deleting the last owner access""" + instance = self.get_object() + resource = getattr(instance, self.resource_field_name) + + # Check if the access being deleted is the last owner access for the resource + if ( + instance.role == "owner" + and resource.accesses.filter(role="owner").count() == 1 + ): + return drf.response.Response( + {"detail": "Cannot delete the last owner access for the resource."}, + status=drf.status.HTTP_403_FORBIDDEN, + ) + + return super().destroy(request, *args, **kwargs) + + def perform_update(self, serializer): + """Check that we don't change the role if it leads to losing the last owner.""" + instance = serializer.instance + + # Check if the role is being updated and the new role is not "owner" + if ( + "role" in self.request.data + and self.request.data["role"] != models.RoleChoices.OWNER + ): + resource = getattr(instance, self.resource_field_name) + # Check if the access being updated is the last owner access for the resource + if ( + instance.role == models.RoleChoices.OWNER + and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 + ): + message = "Cannot change the role to a non-owner role for the last owner access." + raise drf.exceptions.PermissionDenied({"detail": message}) + + serializer.save() + + +class DocumentMetadata(drf.metadata.SimpleMetadata): + """Custom metadata class to add information""" + + def determine_metadata(self, request, view): + """Add language choices only for the list endpoint.""" + simple_metadata = super().determine_metadata(request, view) + + if request.path.endswith("/documents/"): + simple_metadata["actions"]["POST"]["language"] = { + "choices": [ + {"value": code, "display_name": name} + for code, name in enums.ALL_LANGUAGES.items() + ] + } + return simple_metadata + + +# pylint: disable=too-many-public-methods +class DocumentViewSet( + SerializerPerActionMixin, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """ + DocumentViewSet API. + + This view set provides CRUD operations and additional actions for managing documents. + Supports filtering, ordering, and annotations for enhanced querying capabilities. + + ### API Endpoints: + 1. **List**: Retrieve a paginated list of documents. + Example: GET /documents/?page=2 + 2. **Retrieve**: Get a specific document by its ID. + Example: GET /documents/{id}/ + 3. **Create**: Create a new document. + Example: POST /documents/ + 4. **Update**: Update a document by its ID. + Example: PUT /documents/{id}/ + 5. **Delete**: Soft delete a document by its ID. + Example: DELETE /documents/{id}/ + + ### Additional Actions: + 1. **Trashbin**: List soft deleted documents for a document owner + Example: GET /documents/{id}/trashbin/ + + 2. **Children**: List or create child documents. + Example: GET, POST /documents/{id}/children/ + + 3. **Versions List**: Retrieve version history of a document. + Example: GET /documents/{id}/versions/ + + 4. **Version Detail**: Get or delete a specific document version. + Example: GET, DELETE /documents/{id}/versions/{version_id}/ + + 5. **Favorite**: Get list of favorite documents for a user. Mark or unmark + a document as favorite. + Examples: + - GET /documents/favorite/ + - POST, DELETE /documents/{id}/favorite/ + + 6. **Create for Owner**: Create a document via server-to-server on behalf of a user. + Example: POST /documents/create-for-owner/ + + 7. **Link Configuration**: Update document link configuration. + Example: PUT /documents/{id}/link-configuration/ + + 8. **Attachment Upload**: Upload a file attachment for the document. + Example: POST /documents/{id}/attachment-upload/ + + 9. **Media Auth**: Authorize access to document media. + Example: GET /documents/media-auth/ + + 10. **AI Transform**: Apply a transformation action on a piece of text with AI. + Example: POST /documents/{id}/ai-transform/ + Expected data: + - text (str): The input text. + - action (str): The transformation type, one of [prompt, correct, rephrase, summarize]. + Returns: JSON response with the processed text. + Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. + + 11. **AI Translate**: Translate a piece of text with AI. + Example: POST /documents/{id}/ai-translate/ + Expected data: + - text (str): The input text. + - language (str): The target language, chosen from settings.LANGUAGES. + Returns: JSON response with the translated text. + Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. + + ### Ordering: created_at, updated_at, is_favorite, title + + Example: + - Ascending: GET /api/v1.0/documents/?ordering=created_at + - Desceding: GET /api/v1.0/documents/?ordering=-title + + ### Filtering: + - `is_creator_me=true`: Returns documents created by the current user. + - `is_creator_me=false`: Returns documents created by other users. + - `is_favorite=true`: Returns documents marked as favorite by the current user + - `is_favorite=false`: Returns documents not marked as favorite by the current user + - `title=hello`: Returns documents which title contains the "hello" string + + Example: + - GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true + - GET /api/v1.0/documents/?is_creator_me=false&title=hello + + ### Annotations: + 1. **is_favorite**: Indicates whether the document is marked as favorite by the current user. + 2. **user_roles**: Roles the current user has on the document or its ancestors. + + ### Notes: + - Only the highest ancestor in a document hierarchy is shown in list views. + - Implements soft delete logic to retain document tree structures. + """ + + metadata_class = DocumentMetadata + ordering = ["-updated_at"] + ordering_fields = ["created_at", "updated_at", "title"] + pagination_class = Pagination + permission_classes = [ + permissions.DocumentAccessPermission, + ] + queryset = models.Document.objects.all() + serializer_class = serializers.DocumentSerializer + ai_translate_serializer_class = serializers.AITranslateSerializer + children_serializer_class = serializers.ListDocumentSerializer + descendants_serializer_class = serializers.ListDocumentSerializer + list_serializer_class = serializers.ListDocumentSerializer + trashbin_serializer_class = serializers.ListDocumentSerializer + tree_serializer_class = serializers.ListDocumentSerializer + + def annotate_is_favorite(self, queryset): + """ + Annotate document queryset with the favorite status for the current user. + """ + user = self.request.user + + if user.is_authenticated: + favorite_exists_subquery = models.DocumentFavorite.objects.filter( + document_id=db.OuterRef("pk"), user=user + ) + return queryset.annotate(is_favorite=db.Exists(favorite_exists_subquery)) + + return queryset.annotate(is_favorite=db.Value(False)) + + def annotate_user_roles(self, queryset): + """ + Annotate document queryset with the roles of the current user + on the document or its ancestors. + """ + user = self.request.user + output_field = ArrayField(base_field=db.CharField()) + + if user.is_authenticated: + user_roles_subquery = models.DocumentAccess.objects.filter( + db.Q(user=user) | db.Q(team__in=user.teams), + document__path=Left(db.OuterRef("path"), Length("document__path")), + ).values_list("role", flat=True) + + return queryset.annotate( + user_roles=db.Func( + user_roles_subquery, function="ARRAY", output_field=output_field + ) + ) + + return queryset.annotate( + user_roles=db.Value([], output_field=output_field), + ) + + def get_queryset(self): + """Get queryset performing all annotation and filtering on the document tree structure.""" + user = self.request.user + queryset = super().get_queryset() + + # Only list views need filtering and annotation + if self.detail: + return queryset + + if not user.is_authenticated: + return queryset.none() + + queryset = queryset.filter(ancestors_deleted_at__isnull=True) + + # Filter documents to which the current user has access... + access_documents_ids = models.DocumentAccess.objects.filter( + db.Q(user=user) | db.Q(team__in=user.teams) + ).values_list("document_id", flat=True) + + # ...or that were previously accessed and are not restricted + traced_documents_ids = models.LinkTrace.objects.filter(user=user).values_list( + "document_id", flat=True + ) + + return queryset.filter( + db.Q(id__in=access_documents_ids) + | ( + db.Q(id__in=traced_documents_ids) + & ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED) + ) + ) + + def filter_queryset(self, queryset): + """Override to apply annotations to generic views.""" + queryset = super().filter_queryset(queryset) + queryset = self.annotate_is_favorite(queryset) + queryset = self.annotate_user_roles(queryset) + return queryset + + def get_response_for_queryset(self, queryset): + """Return paginated response for the queryset if requested.""" + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return drf.response.Response(serializer.data) + + def list(self, request, *args, **kwargs): + """ + Returns a DRF response containing the filtered, annotated and ordered document list. + + This method applies filtering based on request parameters using `ListDocumentFilter`. + It performs early filtering on model fields, annotates user roles, and removes + descendant documents to keep only the highest ancestors readable by the current user. + + Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are + applied before ordering and returning the response. + """ + queryset = ( + self.get_queryset() + ) # Not calling filter_queryset. We do our own cooking. + + filterset = ListDocumentFilter( + self.request.GET, queryset=queryset, request=self.request + ) + if not filterset.is_valid(): + raise drf.exceptions.ValidationError(filterset.errors) + filter_data = filterset.form.cleaned_data + + # Filter as early as possible on fields that are available on the model + for field in ["is_creator_me", "title"]: + queryset = filterset.filters[field].filter(queryset, filter_data[field]) + + queryset = self.annotate_user_roles(queryset) + + # Among the results, we may have documents that are ancestors/descendants + # of each other. In this case we want to keep only the highest ancestors. + root_paths = utils.filter_root_paths( + queryset.order_by("path").values_list("path", flat=True), + skip_sorting=True, + ) + queryset = queryset.filter(path__in=root_paths) + + # Annotate the queryset with an attribute marking instances as highest ancestor + # in order to save some time while computing abilities on the instance + queryset = queryset.annotate( + is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField()) + ) + + # Annotate favorite status and filter if applicable as late as possible + queryset = self.annotate_is_favorite(queryset) + queryset = filterset.filters["is_favorite"].filter( + queryset, filter_data["is_favorite"] + ) + + # Apply ordering only now that everything is filtered and annotated + queryset = filters.OrderingFilter().filter_queryset( + self.request, queryset, self + ) + + return self.get_response_for_queryset(queryset) + + def retrieve(self, request, *args, **kwargs): + """ + Add a trace that the document was accessed by a user. This is used to list documents + on a user's list view even though the user has no specific role in the document (link + access when the link reach configuration of the document allows it). + """ + user = self.request.user + instance = self.get_object() + serializer = self.get_serializer(instance) + + # The `create` query generates 5 db queries which are much less efficient than an + # `exists` query. The user will visit the document many times after the first visit + # so that's what we should optimize for. + if ( + user.is_authenticated + and not instance.link_traces.filter(user=user).exists() + ): + models.LinkTrace.objects.create(document=instance, user=request.user) + + return drf.response.Response(serializer.data) + + @transaction.atomic + def perform_create(self, serializer): + """Set the current user as creator and owner of the newly created object.""" + + # locks the table to ensure safe concurrent access + with connection.cursor() as cursor: + cursor.execute( + f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001 + "IN SHARE ROW EXCLUSIVE MODE;" + ) + + obj = models.Document.add_root( + creator=self.request.user, + **serializer.validated_data, + ) + serializer.instance = obj + models.DocumentAccess.objects.create( + document=obj, + user=self.request.user, + role=models.RoleChoices.OWNER, + ) + + def perform_destroy(self, instance): + """Override to implement a soft delete instead of dumping the record in database.""" + instance.soft_delete() + + @drf.decorators.action( + detail=False, + methods=["get"], + permission_classes=[permissions.IsAuthenticated], + ) + def favorite_list(self, request, *args, **kwargs): + """Get list of favorite documents for the current user.""" + user = request.user + + favorite_documents_ids = models.DocumentFavorite.objects.filter( + user=user + ).values_list("document_id", flat=True) + + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.filter(id__in=favorite_documents_ids) + return self.get_response_for_queryset(queryset) + + @drf.decorators.action( + detail=False, + methods=["get"], + ) + def trashbin(self, request, *args, **kwargs): + """ + Retrieve soft-deleted documents for which the current user has the owner role. + + The selected documents are those deleted within the cutoff period defined in the + settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted. + """ + queryset = self.queryset.filter( + deleted_at__isnull=False, + deleted_at__gte=models.get_trashbin_cutoff(), + ) + queryset = self.annotate_user_roles(queryset) + queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER]) + + return self.get_response_for_queryset(queryset) + + @drf.decorators.action( + authentication_classes=[authentication.ServerToServerAuthentication], + detail=False, + methods=["post"], + permission_classes=[], + url_path="create-for-owner", + ) + @transaction.atomic + def create_for_owner(self, request): + """ + Create a document on behalf of a specified owner (pre-existing user or invited). + """ + + # locks the table to ensure safe concurrent access + with connection.cursor() as cursor: + cursor.execute( + f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001 + "IN SHARE ROW EXCLUSIVE MODE;" + ) + + # Deserialize and validate the data + serializer = serializers.ServerCreateDocumentSerializer(data=request.data) + if not serializer.is_valid(): + return drf_response.Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + document = serializer.save() + + return drf_response.Response( + {"id": str(document.id)}, status=status.HTTP_201_CREATED + ) + + @drf.decorators.action(detail=True, methods=["post"]) + @transaction.atomic + def move(self, request, *args, **kwargs): + """ + Move a document to another location within the document tree. + + The user must be an administrator or owner of both the document being moved + and the target parent document. + """ + user = request.user + document = self.get_object() # including permission checks + + # Validate the input payload + serializer = serializers.MoveDocumentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + target_document_id = validated_data["target_document_id"] + try: + target_document = models.Document.objects.get( + id=target_document_id, ancestors_deleted_at__isnull=True + ) + except models.Document.DoesNotExist: + return drf.response.Response( + {"target_document_id": "Target parent document does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + position = validated_data["position"] + message = None + + if position in [ + enums.MoveNodePositionChoices.FIRST_CHILD, + enums.MoveNodePositionChoices.LAST_CHILD, + ]: + if not target_document.get_abilities(user).get("move"): + message = ( + "You do not have permission to move documents " + "as a child to this target document." + ) + elif not target_document.is_root(): + if not target_document.get_parent().get_abilities(user).get("move"): + message = ( + "You do not have permission to move documents " + "as a sibling of this target document." + ) + + if message: + return drf.response.Response( + {"target_document_id": message}, + status=status.HTTP_400_BAD_REQUEST, + ) + + document.move(target_document, pos=position) + + return drf.response.Response( + {"message": "Document moved successfully."}, status=status.HTTP_200_OK + ) + + @drf.decorators.action( + detail=True, + methods=["post"], + ) + def restore(self, request, *args, **kwargs): + """ + Restore a soft-deleted document if it was deleted less than x days ago. + """ + document = self.get_object() + document.restore() + + return drf_response.Response( + {"detail": "Document has been successfully restored."}, + status=status.HTTP_200_OK, + ) + + @drf.decorators.action( + detail=True, + methods=["get", "post"], + ordering=["path"], + ) + def children(self, request, *args, **kwargs): + """Handle listing and creating children of a document""" + document = self.get_object() + + if request.method == "POST": + # Create a child document + serializer = serializers.DocumentSerializer( + data=request.data, context=self.get_serializer_context() + ) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + # "select_for_update" locks the table to ensure safe concurrent access + locked_parent = models.Document.objects.select_for_update().get( + pk=document.pk + ) + + child_document = locked_parent.add_child( + creator=request.user, + **serializer.validated_data, + ) + models.DocumentAccess.objects.create( + document=child_document, + user=request.user, + role=models.RoleChoices.OWNER, + ) + # Set the created instance to the serializer + serializer.instance = child_document + + headers = self.get_success_headers(serializer.data) + return drf.response.Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + # GET: List children + queryset = document.get_children().filter(ancestors_deleted_at__isnull=True) + queryset = self.filter_queryset(queryset) + + filterset = DocumentFilter(request.GET, queryset=queryset) + if not filterset.is_valid(): + raise drf.exceptions.ValidationError(filterset.errors) + + queryset = filterset.qs + + return self.get_response_for_queryset(queryset) + + @drf.decorators.action( + detail=True, + methods=["get"], + ordering=["path"], + ) + def descendants(self, request, *args, **kwargs): + """Handle listing descendants of a document""" + document = self.get_object() + + queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True) + queryset = self.filter_queryset(queryset) + + filterset = DocumentFilter(request.GET, queryset=queryset) + if not filterset.is_valid(): + raise drf.exceptions.ValidationError(filterset.errors) + + queryset = filterset.qs + + return self.get_response_for_queryset(queryset) + + @drf.decorators.action( + detail=True, + methods=["get"], + ordering=["path"], + ) + def tree(self, request, pk, *args, **kwargs): + """ + List ancestors tree above the document. + What we need to display is the tree structure opened for the current document. + """ + try: + current_document = self.queryset.only("depth", "path").get(pk=pk) + except models.Document.DoesNotExist as excpt: + raise drf.exceptions.NotFound from excpt + + ancestors = ( + (current_document.get_ancestors() | self.queryset.filter(pk=pk)) + .filter(ancestors_deleted_at__isnull=True) + .order_by("path") + ) + + # Get the highest readable ancestor + highest_readable = ( + ancestors.readable_per_se(request.user).only("depth", "path").first() + ) + if highest_readable is None: + raise ( + drf.exceptions.PermissionDenied() + if request.user.is_authenticated + else drf.exceptions.NotAuthenticated() + ) + paths_links_mapping = {} + ancestors_links = [] + children_clause = db.Q() + for ancestor in ancestors: + if ancestor.depth < highest_readable.depth: + continue + + children_clause |= db.Q( + path__startswith=ancestor.path, depth=ancestor.depth + 1 + ) + + # Compute cache for ancestors links to avoid many queries while computing + # abilities for his documents in the tree! + ancestors_links.append( + {"link_reach": ancestor.link_reach, "link_role": ancestor.link_role} + ) + paths_links_mapping[ancestor.path] = ancestors_links.copy() + + children = self.queryset.filter(children_clause, deleted_at__isnull=True) + + queryset = ancestors.filter(depth__gte=highest_readable.depth) | children + queryset = queryset.order_by("path") + # Annotate if the current document is the highest ancestor for the user + queryset = queryset.annotate( + is_highest_ancestor_for_user=db.Case( + db.When( + path=db.Value(highest_readable.path), + then=db.Value(True), + ), + default=db.Value(False), + output_field=db.BooleanField(), + ) + ) + queryset = self.annotate_user_roles(queryset) + queryset = self.annotate_is_favorite(queryset) + + # Pass ancestors' links definitions to the serializer as a context variable + # in order to allow saving time while computing abilities on the instance + serializer = self.get_serializer( + queryset, + many=True, + context={ + "request": request, + "paths_links_mapping": paths_links_mapping, + }, + ) + return drf.response.Response( + utils.nest_tree(serializer.data, self.queryset.model.steplen) + ) + + @drf.decorators.action( + detail=True, + methods=["post"], + permission_classes=[permissions.IsAuthenticated, permissions.AccessPermission], + url_path="duplicate", + ) + @transaction.atomic + def duplicate(self, request, *args, **kwargs): + """ + Duplicate a document and store the links to attached files in the duplicated + document to allow cross-access. + + Optionally duplicates accesses if `with_accesses` is set to true + in the payload. + """ + # Get document while checking permissions + document = self.get_object() + + serializer = serializers.DocumentDuplicationSerializer( + data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + with_accesses = serializer.validated_data.get("with_accesses", False) + + base64_yjs_content = document.content + + # Duplicate the document instance + link_kwargs = ( + {"link_reach": document.link_reach, "link_role": document.link_role} + if with_accesses + else {} + ) + extracted_attachments = set(extract_attachments(document.content)) + attachments = list(extracted_attachments & set(document.attachments)) + duplicated_document = document.add_sibling( + "right", + title=capfirst(_("copy of {title}").format(title=document.title)), + content=base64_yjs_content, + attachments=attachments, + duplicated_from=document, + creator=request.user, + **link_kwargs, + ) + + # Always add the logged-in user as OWNER + accesses_to_create = [ + models.DocumentAccess( + document=duplicated_document, + user=request.user, + role=models.RoleChoices.OWNER, + ) + ] + + # If accesses should be duplicated, add other users' accesses as per original document + if with_accesses: + original_accesses = models.DocumentAccess.objects.filter( + document=document + ).exclude(user=request.user) + + accesses_to_create.extend( + models.DocumentAccess( + document=duplicated_document, + user_id=access.user_id, + team=access.team, + role=access.role, + ) + for access in original_accesses + ) + + # Bulk create all the duplicated accesses + models.DocumentAccess.objects.bulk_create(accesses_to_create) + + return drf_response.Response( + {"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED + ) + + @drf.decorators.action(detail=True, methods=["get"], url_path="versions") + def versions_list(self, request, *args, **kwargs): + """ + Return the document's versions but only those created after the user got access + to the document + """ + user = request.user + if not user.is_authenticated: + raise drf.exceptions.PermissionDenied("Authentication required.") + + # Validate query parameters using dedicated serializer + serializer = serializers.VersionFilterSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + document = self.get_object() + + # Users should not see version history dating from before they gained access to the + # document. Filter to get the minimum access date for the logged-in user + access_queryset = models.DocumentAccess.objects.filter( + db.Q(user=user) | db.Q(team__in=user.teams), + document__path=Left(db.Value(document.path), Length("document__path")), + ).aggregate(min_date=db.Min("created_at")) + + # Handle the case where the user has no accesses + min_datetime = access_queryset["min_date"] + if not min_datetime: + return drf.exceptions.PermissionDenied( + "Only users with specific access can see version history" + ) + + versions_data = document.get_versions_slice( + from_version_id=serializer.validated_data.get("version_id"), + min_datetime=min_datetime, + page_size=serializer.validated_data.get("page_size"), + ) + + return drf.response.Response(versions_data) + + @drf.decorators.action( + detail=True, + methods=["get", "delete"], + url_path="versions/(?P[0-9a-z-]+)", + ) + # pylint: disable=unused-argument + def versions_detail(self, request, pk, version_id, *args, **kwargs): + """Custom action to retrieve a specific version of a document""" + document = self.get_object() + + try: + response = document.get_content_response(version_id=version_id) + except (FileNotFoundError, ClientError) as err: + raise Http404 from err + + # Don't let users access versions that were created before they were given access + # to the document + user = request.user + min_datetime = min( + access.created_at + for access in models.DocumentAccess.objects.filter( + db.Q(user=user) | db.Q(team__in=user.teams), + document__path=Left(db.Value(document.path), Length("document__path")), + ) + ) + + if response["LastModified"] < min_datetime: + raise Http404 + + if request.method == "DELETE": + response = document.delete_version(version_id) + return drf.response.Response( + status=response["ResponseMetadata"]["HTTPStatusCode"] + ) + + return drf.response.Response( + { + "content": response["Body"].read().decode("utf-8"), + "last_modified": response["LastModified"], + "id": version_id, + } + ) + + @drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration") + def link_configuration(self, request, *args, **kwargs): + """Update link configuration with specific rights (cf get_abilities).""" + # Check permissions first + document = self.get_object() + + # Deserialize and validate the data + serializer = serializers.LinkDocumentSerializer( + document, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + + serializer.save() + + # Notify collaboration server about the link updated + CollaborationService().reset_connections(str(document.id)) + + return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) + + @drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") + def favorite(self, request, *args, **kwargs): + """ + Mark or unmark the document as a favorite for the logged-in user based on the HTTP method. + """ + # Check permissions first + document = self.get_object() + user = request.user + + if request.method == "POST": + # Try to mark as favorite + try: + models.DocumentFavorite.objects.create(document=document, user=user) + except ValidationError: + return drf.response.Response( + {"detail": "Document already marked as favorite"}, + status=drf.status.HTTP_200_OK, + ) + return drf.response.Response( + {"detail": "Document marked as favorite"}, + status=drf.status.HTTP_201_CREATED, + ) + + # Handle DELETE method to unmark as favorite + deleted, _ = models.DocumentFavorite.objects.filter( + document=document, user=user + ).delete() + if deleted: + return drf.response.Response( + {"detail": "Document unmarked as favorite"}, + status=drf.status.HTTP_204_NO_CONTENT, + ) + return drf.response.Response( + {"detail": "Document was already not marked as favorite"}, + status=drf.status.HTTP_200_OK, + ) + + @drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload") + def attachment_upload(self, request, *args, **kwargs): + """Upload a file related to a given document""" + # Check permissions first + document = self.get_object() + + # Validate metadata in payload + serializer = serializers.FileUploadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Generate a generic yet unique filename to store the image in object storage + file_id = uuid.uuid4() + ext = serializer.validated_data["expected_extension"] + + # Prepare metadata for storage + extra_args = { + "Metadata": { + "owner": str(request.user.id), + "status": enums.DocumentAttachmentStatus.PROCESSING, + }, + "ContentType": serializer.validated_data["content_type"], + } + file_unsafe = "" + if serializer.validated_data["is_unsafe"]: + extra_args["Metadata"]["is_unsafe"] = "true" + file_unsafe = "-unsafe" + + key = f"{document.key_base}/{enums.ATTACHMENTS_FOLDER:s}/{file_id!s}{file_unsafe}.{ext:s}" + + file_name = serializer.validated_data["file_name"] + if ( + not serializer.validated_data["content_type"].startswith("image/") + or serializer.validated_data["is_unsafe"] + ): + extra_args.update( + {"ContentDisposition": f'attachment; filename="{file_name:s}"'} + ) + else: + extra_args.update( + {"ContentDisposition": f'inline; filename="{file_name:s}"'} + ) + + file = serializer.validated_data["file"] + default_storage.connection.meta.client.upload_fileobj( + file, default_storage.bucket_name, key, ExtraArgs=extra_args + ) + + # Make the attachment readable by document readers + document.attachments.append(key) + document.save() + + malware_detection.analyse_file(key, document_id=document.id) + + url = reverse( + "documents-media-check", + kwargs={"pk": document.id}, + ) + parameters = urlencode({"key": key}) + + return drf.response.Response( + { + "file": f"{url:s}?{parameters:s}", + }, + status=drf.status.HTTP_201_CREATED, + ) + + def _auth_get_original_url(self, request): + """ + Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header. + Raises PermissionDenied if the header is missing. + + The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. + See corresponding ingress configuration in Helm chart and read about the + nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress + is configured to do this. + + Based on the original url and the logged in user, we must decide if we authorize Nginx + to let this request go through (by returning a 200 code) or if we block it (by returning + a 403 error). Note that we return 403 errors without any further details for security + reasons. + """ + # Extract the original URL from the request header + original_url = request.META.get("HTTP_X_ORIGINAL_URL") + if not original_url: + logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest") + raise drf.exceptions.PermissionDenied() + + logger.debug("Original url: '%s'", original_url) + return urlparse(original_url) + + def _auth_get_url_params(self, pattern, fragment): + """ + Extracts URL parameters from the given fragment using the specified regex pattern. + Raises PermissionDenied if parameters cannot be extracted. + """ + match = pattern.search(fragment) + try: + return match.groupdict() + except (ValueError, AttributeError) as exc: + logger.debug("Failed to extract parameters from subrequest URL: %s", exc) + raise drf.exceptions.PermissionDenied() from exc + + @drf.decorators.action(detail=False, methods=["get"], url_path="media-auth") + def media_auth(self, request, *args, **kwargs): + """ + This view is used by an Nginx subrequest to control access to a document's + attachment file. + + When we let the request go through, we compute authorization headers that will be added to + the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers + annotation. The request will then be proxied to the object storage backend who will + respond with the file after checking the signature included in headers. + """ + parsed_url = self._auth_get_original_url(request) + url_params = self._auth_get_url_params( + enums.MEDIA_STORAGE_URL_PATTERN, parsed_url.path + ) + + user = request.user + key = f"{url_params['pk']:s}/{url_params['attachment']:s}" + + # Look for a document to which the user has access and that includes this attachment + # We must look into all descendants of any document to which the user has access per se + readable_per_se_paths = ( + self.queryset.readable_per_se(user) + .order_by("path") + .values_list("path", flat=True) + ) + + attachments_documents = ( + self.queryset.filter(attachments__contains=[key]) + .only("path") + .order_by("path") + ) + readable_attachments_paths = filter_descendants( + [doc.path for doc in attachments_documents], + readable_per_se_paths, + skip_sorting=True, + ) + + if not readable_attachments_paths: + logger.debug("User '%s' lacks permission for attachment", user) + raise drf.exceptions.PermissionDenied() + + # Check if the attachment is ready + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + try: + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + except ClientError as err: + raise drf.exceptions.PermissionDenied() from err + metadata = head_resp.get("Metadata", {}) + # In order to be compatible with existing upload without `status` metadata, + # we consider them as ready. + if ( + metadata.get("status", enums.DocumentAttachmentStatus.READY) + != enums.DocumentAttachmentStatus.READY + ): + raise drf.exceptions.PermissionDenied() + + # Generate S3 authorization headers using the extracted URL parameters + request = utils.generate_s3_authorization_headers(key) + + return drf.response.Response("authorized", headers=request.headers, status=200) + + @drf.decorators.action(detail=True, methods=["get"], url_path="media-check") + def media_check(self, request, *args, **kwargs): + """ + Check if the media is ready to be served. + """ + document = self.get_object() + + key = request.query_params.get("key") + if not key: + return drf.response.Response( + {"detail": "Missing 'key' query parameter"}, + status=drf.status.HTTP_400_BAD_REQUEST, + ) + + if key not in document.attachments: + return drf.response.Response( + {"detail": "Attachment missing"}, + status=drf.status.HTTP_404_NOT_FOUND, + ) + + # Check if the attachment is ready + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + try: + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + except ClientError as err: + logger.error("Client Error fetching file %s metadata: %s", key, err) + return drf.response.Response( + {"detail": "Media not found"}, + status=drf.status.HTTP_404_NOT_FOUND, + ) + metadata = head_resp.get("Metadata", {}) + + body = { + "status": metadata.get("status", enums.DocumentAttachmentStatus.PROCESSING), + } + if metadata.get("status") == enums.DocumentAttachmentStatus.READY: + body = { + "status": enums.DocumentAttachmentStatus.READY, + "file": f"{settings.MEDIA_URL:s}{key:s}", + } + + return drf.response.Response(body, status=drf.status.HTTP_200_OK) + + @drf.decorators.action( + detail=True, + methods=["post"], + name="Apply a transformation action on a piece of text with AI", + url_path="ai-transform", + throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle], + ) + def ai_transform(self, request, *args, **kwargs): + """ + POST /api/v1.0/documents//ai-transform + with expected data: + - text: str + - action: str [prompt, correct, rephrase, summarize] + Return JSON response with the processed text. + """ + # Check permissions first + self.get_object() + + serializer = serializers.AITransformSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + text = serializer.validated_data["text"] + action = serializer.validated_data["action"] + + response = AIService().transform(text, action) + + return drf.response.Response(response, status=drf.status.HTTP_200_OK) + + @drf.decorators.action( + detail=True, + methods=["post"], + name="Translate a piece of text with AI", + url_path="ai-translate", + throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle], + ) + def ai_translate(self, request, *args, **kwargs): + """ + POST /api/v1.0/documents//ai-translate + with expected data: + - text: str + - language: str [settings.LANGUAGES] + Return JSON response with the translated text. + """ + # Check permissions first + self.get_object() + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + text = serializer.validated_data["text"] + language = serializer.validated_data["language"] + + response = AIService().translate(text, language) + + return drf.response.Response(response, status=drf.status.HTTP_200_OK) + + @drf.decorators.action( + detail=True, + methods=["get"], + name="", + url_path="cors-proxy", + ) + def cors_proxy(self, request, *args, **kwargs): + """ + GET /api/v1.0/documents//cors-proxy + Act like a proxy to fetch external resources and bypass CORS restrictions. + """ + url = request.query_params.get("url") + if not url: + return drf.response.Response( + {"detail": "Missing 'url' query parameter"}, + status=drf.status.HTTP_400_BAD_REQUEST, + ) + + # Check for permissions. + self.get_object() + + url = unquote(url) + + try: + response = requests.get( + url, + stream=True, + headers={ + "User-Agent": request.headers.get("User-Agent", ""), + "Accept": request.headers.get("Accept", ""), + }, + timeout=10, + ) + content_type = response.headers.get("Content-Type", "") + + if not content_type.startswith("image/"): + return drf.response.Response( + status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + ) + + # Use StreamingHttpResponse with the response's iter_content to properly stream the data + proxy_response = StreamingHttpResponse( + streaming_content=response.iter_content(chunk_size=8192), + content_type=content_type, + headers={ + "Content-Disposition": "attachment;", + "Content-Security-Policy": "default-src 'none'; img-src 'none' data:;", + }, + status=response.status_code, + ) + + return proxy_response + + except requests.RequestException as e: + logger.error("Proxy request failed: %s", str(e)) + return drf_response.Response( + {"error": f"Failed to fetch resource: {e!s}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class DocumentAccessViewSet( + ResourceAccessViewsetMixin, + viewsets.ModelViewSet, +): + """ + API ViewSet for all interactions with document accesses. + + GET /api/v1.0/documents//accesses/: + Return list of all document accesses related to the logged-in user or one + document access if an id is provided. + + POST /api/v1.0/documents//accesses/ with expected data: + - user: str + - role: str [administrator|editor|reader] + Return newly created document access + + PUT /api/v1.0/documents//accesses// with expected data: + - role: str [owner|admin|editor|reader] + Return updated document access + + PATCH /api/v1.0/documents//accesses// with expected data: + - role: str [owner|admin|editor|reader] + Return partially updated document access + + DELETE /api/v1.0/documents//accesses// + Delete targeted document access + """ + + lookup_field = "pk" + pagination_class = Pagination + permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] + queryset = models.DocumentAccess.objects.select_related("user").all() + resource_field_name = "document" + serializer_class = serializers.DocumentAccessSerializer + is_current_user_owner_or_admin = False + + def get_queryset(self): + """Return the queryset according to the action.""" + queryset = super().get_queryset() + + if self.action == "list": + try: + document = models.Document.objects.get(pk=self.kwargs["resource_id"]) + except models.Document.DoesNotExist: + return queryset.none() + + roles = set(document.get_roles(self.request.user)) + is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES))) + self.is_current_user_owner_or_admin = is_owner_or_admin + if not is_owner_or_admin: + # Return only the document owner access + queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES) + + return queryset + + def get_serializer_class(self): + if self.action == "list" and not self.is_current_user_owner_or_admin: + return serializers.DocumentAccessLightSerializer + + return super().get_serializer_class() + + def perform_create(self, serializer): + """Add a new access to the document and send an email to the new added user.""" + access = serializer.save() + + access.document.send_invitation_email( + access.user.email, + access.role, + self.request.user, + access.user.language + or self.request.user.language + or settings.LANGUAGE_CODE, + ) + + def perform_update(self, serializer): + """Update an access to the document and notify the collaboration server.""" + access = serializer.save() + + access_user_id = None + if access.user: + access_user_id = str(access.user.id) + + # Notify collaboration server about the access change + CollaborationService().reset_connections( + str(access.document.id), access_user_id + ) + + def perform_destroy(self, instance): + """Delete an access to the document and notify the collaboration server.""" + instance.delete() + + # Notify collaboration server about the access removed + CollaborationService().reset_connections( + str(instance.document.id), str(instance.user.id) + ) + + +class TemplateViewSet( + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """Template ViewSet""" + + filter_backends = [drf.filters.OrderingFilter] + permission_classes = [ + permissions.IsAuthenticatedOrSafe, + permissions.AccessPermission, + ] + ordering = ["-created_at"] + ordering_fields = ["created_at", "updated_at", "title"] + serializer_class = serializers.TemplateSerializer + queryset = models.Template.objects.all() + + def get_queryset(self): + """Custom queryset to get user related templates.""" + queryset = super().get_queryset() + user = self.request.user + + if not user.is_authenticated: + return queryset + + user_roles_query = ( + models.TemplateAccess.objects.filter( + db.Q(user=user) | db.Q(team__in=user.teams), + template_id=db.OuterRef("pk"), + ) + .values("template") + .annotate(roles_array=ArrayAgg("role")) + .values("roles_array") + ) + return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct() + + def list(self, request, *args, **kwargs): + """Restrict templates returned by the list endpoint""" + queryset = self.filter_queryset(self.get_queryset()) + user = self.request.user + if user.is_authenticated: + queryset = queryset.filter( + db.Q(accesses__user=user) + | db.Q(accesses__team__in=user.teams) + | db.Q(is_public=True) + ) + else: + queryset = queryset.filter(is_public=True) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return drf.response.Response(serializer.data) + + @transaction.atomic + def perform_create(self, serializer): + """Set the current user as owner of the newly created object.""" + obj = serializer.save() + models.TemplateAccess.objects.create( + template=obj, + user=self.request.user, + role=models.RoleChoices.OWNER, + ) + + +class TemplateAccessViewSet( + ResourceAccessViewsetMixin, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """ + API ViewSet for all interactions with template accesses. + + GET /api/v1.0/templates//accesses/: + Return list of all template accesses related to the logged-in user or one + template access if an id is provided. + + POST /api/v1.0/templates//accesses/ with expected data: + - user: str + - role: str [administrator|editor|reader] + Return newly created template access + + PUT /api/v1.0/templates//accesses// with expected data: + - role: str [owner|admin|editor|reader] + Return updated template access + + PATCH /api/v1.0/templates//accesses// with expected data: + - role: str [owner|admin|editor|reader] + Return partially updated template access + + DELETE /api/v1.0/templates//accesses// + Delete targeted template access + """ + + lookup_field = "pk" + pagination_class = Pagination + permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] + queryset = models.TemplateAccess.objects.select_related("user").all() + resource_field_name = "template" + serializer_class = serializers.TemplateAccessSerializer + + +class InvitationViewset( + drf.mixins.CreateModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """API ViewSet for user invitations to document. + + GET /api/v1.0/documents//invitations/:/ + Return list of invitations related to that document or one + document access if an id is provided. + + POST /api/v1.0/documents//invitations/ with expected data: + - email: str + - role: str [administrator|editor|reader] + Return newly created invitation (issuer and document are automatically set) + + PATCH /api/v1.0/documents//invitations/:/ with expected data: + - role: str [owner|admin|editor|reader] + Return partially updated document invitation + + DELETE /api/v1.0/documents//invitations// + Delete targeted invitation + """ + + lookup_field = "id" + pagination_class = Pagination + permission_classes = [ + permissions.CanCreateInvitationPermission, + permissions.AccessPermission, + ] + queryset = ( + models.Invitation.objects.all() + .select_related("document") + .order_by("-created_at") + ) + serializer_class = serializers.InvitationSerializer + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["resource_id"] = self.kwargs["resource_id"] + return context + + def get_queryset(self): + """Return the queryset according to the action.""" + queryset = super().get_queryset() + queryset = queryset.filter(document=self.kwargs["resource_id"]) + + if self.action == "list": + user = self.request.user + teams = user.teams + + # Determine which role the logged-in user has in the document + user_roles_query = ( + models.DocumentAccess.objects.filter( + db.Q(user=user) | db.Q(team__in=teams), + document=self.kwargs["resource_id"], + ) + .values("document") + .annotate(roles_array=ArrayAgg("role")) + .values("roles_array") + ) + + queryset = ( + # The logged-in user should be administrator or owner to see its accesses + queryset.filter( + db.Q( + document__accesses__user=user, + document__accesses__role__in=models.PRIVILEGED_ROLES, + ) + | db.Q( + document__accesses__team__in=teams, + document__accesses__role__in=models.PRIVILEGED_ROLES, + ), + ) + # Abilities are computed based on logged-in user's role and + # the user role on each document access + .annotate(user_roles=db.Subquery(user_roles_query)) + .distinct() + ) + return queryset + + def perform_create(self, serializer): + """Save invitation to a document then send an email to the invited user.""" + invitation = serializer.save() + + invitation.document.send_invitation_email( + invitation.email, + invitation.role, + self.request.user, + self.request.user.language or settings.LANGUAGE_CODE, + ) + + +class ConfigView(drf.views.APIView): + """API ViewSet for sharing some public settings.""" + + permission_classes = [AllowAny] + + def get(self, request): + """ + GET /api/v1.0/config/ + Return a dictionary of public settings. + """ + array_settings = [ + "AI_FEATURE_ENABLED", + "COLLABORATION_WS_URL", + "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY", + "CRISP_WEBSITE_ID", + "ENVIRONMENT", + "FRONTEND_CSS_URL", + "FRONTEND_HOMEPAGE_FEATURE_ENABLED", + "FRONTEND_THEME", + "MEDIA_BASE_URL", + "POSTHOG_KEY", + "LANGUAGES", + "LANGUAGE_CODE", + "SENTRY_DSN", + ] + dict_settings = {} + for setting in array_settings: + if hasattr(settings, setting): + dict_settings[setting] = getattr(settings, setting) + + dict_settings["theme_customization"] = self._load_theme_customization() + + return drf.response.Response(dict_settings) + + def _load_theme_customization(self): + if not settings.THEME_CUSTOMIZATION_FILE_PATH: + return {} + + cache_key = ( + f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}" + ) + theme_customization = cache.get(cache_key, {}) + if theme_customization: + return theme_customization + + try: + with open( + settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8" + ) as f: + theme_customization = json.load(f) + except FileNotFoundError: + logger.error( + "Configuration file not found: %s", + settings.THEME_CUSTOMIZATION_FILE_PATH, + ) + except json.JSONDecodeError: + logger.error( + "Configuration file is not a valid JSON: %s", + settings.THEME_CUSTOMIZATION_FILE_PATH, + ) + else: + cache.set( + cache_key, + theme_customization, + settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT, + ) + + return theme_customization diff --git a/submissions/devoteam/docs/src/backend/core/apps.py b/submissions/devoteam/docs/src/backend/core/apps.py new file mode 100644 index 00000000..bba7de0f --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/apps.py @@ -0,0 +1,11 @@ +"""Impress Core application""" +# from django.apps import AppConfig +# from django.utils.translation import gettext_lazy as _ + + +# class CoreConfig(AppConfig): +# """Configuration class for the impress core app.""" + +# name = "core" +# app_label = "core" +# verbose_name = _("impress core application") diff --git a/submissions/devoteam/docs/src/backend/core/authentication/__init__.py b/submissions/devoteam/docs/src/backend/core/authentication/__init__.py new file mode 100644 index 00000000..c5fa0c71 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/authentication/__init__.py @@ -0,0 +1,52 @@ +"""Custom authentication classes for the Impress core app""" + +from django.conf import settings + +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + + +class ServerToServerAuthentication(BaseAuthentication): + """ + Custom authentication class for server-to-server requests. + Validates the presence and correctness of the Authorization header. + """ + + AUTH_HEADER = "Authorization" + TOKEN_TYPE = "Bearer" # noqa S105 + + def authenticate(self, request): + """ + Authenticate the server-to-server request by validating the Authorization header. + + This method checks if the Authorization header is present in the request, ensures it + contains a valid token with the correct format, and verifies the token against the + list of allowed server-to-server tokens. If the header is missing, improperly formatted, + or contains an invalid token, an AuthenticationFailed exception is raised. + + Returns: + None: If authentication is successful + (no user is authenticated for server-to-server requests). + + Raises: + AuthenticationFailed: If the Authorization header is missing, malformed, + or contains an invalid token. + """ + auth_header = request.headers.get(self.AUTH_HEADER) + if not auth_header: + raise AuthenticationFailed("Authorization header is missing.") + + # Validate token format and existence + auth_parts = auth_header.split(" ") + if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE: + raise AuthenticationFailed("Invalid authorization header.") + + token = auth_parts[1] + if token not in settings.SERVER_TO_SERVER_API_TOKENS: + raise AuthenticationFailed("Invalid server-to-server token.") + + # Authentication is successful, but no user is authenticated + + def authenticate_header(self, request): + """Return the WWW-Authenticate header value.""" + return f"{self.TOKEN_TYPE} realm='Create document server to server'" diff --git a/submissions/devoteam/docs/src/backend/core/authentication/backends.py b/submissions/devoteam/docs/src/backend/core/authentication/backends.py new file mode 100644 index 00000000..4ea10718 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/authentication/backends.py @@ -0,0 +1,59 @@ +"""Authentication Backends for the Impress core app.""" + +import logging +import os + +from django.conf import settings +from django.core.exceptions import SuspiciousOperation + +from lasuite.oidc_login.backends import ( + OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, +) + +from core.models import DuplicateEmailError + +logger = logging.getLogger(__name__) + +# Settings renamed warnings +if os.environ.get("USER_OIDC_FIELDS_TO_FULLNAME"): + logger.warning( + "USER_OIDC_FIELDS_TO_FULLNAME has been renamed to " + "OIDC_USERINFO_FULLNAME_FIELDS please update your settings." + ) + +if os.environ.get("USER_OIDC_FIELD_TO_SHORTNAME"): + logger.warning( + "USER_OIDC_FIELD_TO_SHORTNAME has been renamed to " + "OIDC_USERINFO_SHORTNAME_FIELD please update your settings." + ) + + +class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): + """Custom OpenID Connect (OIDC) Authentication Backend. + + This class overrides the default OIDC Authentication Backend to accommodate differences + in the User and Identity models, and handles signed and/or encrypted UserInfo response. + """ + + def get_extra_claims(self, user_info): + """ + Return extra claims from user_info. + + Args: + user_info (dict): The user information dictionary. + + Returns: + dict: A dictionary of extra claims. + """ + return { + "full_name": self.compute_full_name(user_info), + "short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD), + } + + def get_existing_user(self, sub, email): + """Fetch existing user by sub or email.""" + + try: + return self.UserModel.objects.get_user_by_sub_or_email(sub, email) + except DuplicateEmailError as err: + raise SuspiciousOperation(err.message) from err diff --git a/submissions/devoteam/docs/src/backend/core/enums.py b/submissions/devoteam/docs/src/backend/core/enums.py new file mode 100644 index 00000000..46e62b2c --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/enums.py @@ -0,0 +1,48 @@ +""" +Core application enums declaration +""" + +import re +from enum import StrEnum + +from django.conf import global_settings, settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +ATTACHMENTS_FOLDER = "attachments" +UUID_REGEX = ( + r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" +) +FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}" +MEDIA_STORAGE_URL_PATTERN = re.compile( + f"{settings.MEDIA_URL:s}(?P{UUID_REGEX:s})/" + f"(?P{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$" +) +MEDIA_STORAGE_URL_EXTRACT = re.compile( + f"{settings.MEDIA_URL:s}({UUID_REGEX}/{ATTACHMENTS_FOLDER}/{UUID_REGEX}{FILE_EXT_REGEX})" +) + + +# In Django's code base, `LANGUAGES` is set by default with all supported languages. +# We can use it for the choice of languages which should not be limited to the few languages +# active in the app. +# pylint: disable=no-member +ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES} + + +class MoveNodePositionChoices(models.TextChoices): + """Defines the possible positions when moving a django-treebeard node.""" + + FIRST_CHILD = "first-child", _("First child") + LAST_CHILD = "last-child", _("Last child") + FIRST_SIBLING = "first-sibling", _("First sibling") + LAST_SIBLING = "last-sibling", _("Last sibling") + LEFT = "left", _("Left") + RIGHT = "right", _("Right") + + +class DocumentAttachmentStatus(StrEnum): + """Defines the possible statuses for an attachment.""" + + PROCESSING = "processing" + READY = "ready" diff --git a/submissions/devoteam/docs/src/backend/core/factories.py b/submissions/devoteam/docs/src/backend/core/factories.py new file mode 100644 index 00000000..d0a641d8 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/factories.py @@ -0,0 +1,237 @@ +# ruff: noqa: S311 +""" +Core application factories +""" + +from django.conf import settings +from django.contrib.auth.hashers import make_password + +import factory.fuzzy +from faker import Faker + +from core import models + +fake = Faker() + +YDOC_HELLO_WORLD_BASE64 = ( + "AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh" + "aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVI" + "ZWxsb4b17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y" + "1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm" + "YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y" + "AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt" + "BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE" + "bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck" + "ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH" + "ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv" + "bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA" + "9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J" + "dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA" +) + + +class UserFactory(factory.django.DjangoModelFactory): + """A factory to random users for testing purposes.""" + + class Meta: + model = models.User + + sub = factory.Sequence(lambda n: f"user{n!s}") + email = factory.Faker("email") + full_name = factory.Faker("name") + short_name = factory.Faker("first_name") + language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES]) + password = make_password("password") + + @factory.post_generation + def with_owned_document(self, create, extracted, **kwargs): + """ + Create a document for which the user is owner to check + that there is no interference + """ + if create and (extracted is True): + UserDocumentAccessFactory(user=self, role="owner") + + @factory.post_generation + def with_owned_template(self, create, extracted, **kwargs): + """ + Create a template for which the user is owner to check + that there is no interference + """ + if create and (extracted is True): + UserTemplateAccessFactory(user=self, role="owner") + + +class ParentNodeFactory(factory.declarations.ParameteredAttribute): + """Custom factory attribute for setting the parent node.""" + + def generate(self, step, params): + """ + Generate a parent node for the factory. + + This method is invoked during the factory's build process to determine the parent + node of the current object being created. If `params` is provided, it uses the factory's + metadata to recursively create or fetch the parent node. Otherwise, it returns `None`. + """ + if not params: + return None + subfactory = step.builder.factory_meta.factory + return step.recurse(subfactory, params) + + +class DocumentFactory(factory.django.DjangoModelFactory): + """A factory to create documents""" + + class Meta: + model = models.Document + django_get_or_create = ("title",) + skip_postgeneration_save = True + + parent = ParentNodeFactory() + + title = factory.Sequence(lambda n: f"document{n}") + excerpt = factory.Sequence(lambda n: f"excerpt{n}") + content = YDOC_HELLO_WORLD_BASE64 + creator = factory.SubFactory(UserFactory) + deleted_at = None + link_reach = factory.fuzzy.FuzzyChoice( + [a[0] for a in models.LinkReachChoices.choices] + ) + link_role = factory.fuzzy.FuzzyChoice( + [r[0] for r in models.LinkRoleChoices.choices] + ) + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """ + Custom creation logic for the factory: creates a document as a child node if + a parent is provided; otherwise, creates it as a root node. + """ + parent = kwargs.pop("parent", None) + + if parent: + # Add as a child node + kwargs["ancestors_deleted_at"] = ( + kwargs.get("ancestors_deleted_at") or parent.ancestors_deleted_at + ) + return parent.add_child(instance=model_class(**kwargs)) + + # Add as a root node + return model_class.add_root(instance=model_class(**kwargs)) + + @factory.lazy_attribute + def ancestors_deleted_at(self): + """Should always be set when "deleted_at" is set.""" + return self.deleted_at + + @factory.post_generation + def users(self, create, extracted, **kwargs): + """Add users to document from a given list of users with or without roles.""" + if create and extracted: + for item in extracted: + if isinstance(item, models.User): + UserDocumentAccessFactory(document=self, user=item) + else: + UserDocumentAccessFactory(document=self, user=item[0], role=item[1]) + + @factory.post_generation + def teams(self, create, extracted, **kwargs): + """Add teams to document from a given list of teams with or without roles.""" + if create and extracted: + for item in extracted: + if isinstance(item, str): + TeamDocumentAccessFactory(document=self, team=item) + else: + TeamDocumentAccessFactory(document=self, team=item[0], role=item[1]) + + @factory.post_generation + def link_traces(self, create, extracted, **kwargs): + """Add link traces to document from a given list of users.""" + if create and extracted: + for item in extracted: + models.LinkTrace.objects.create(document=self, user=item) + + @factory.post_generation + def favorited_by(self, create, extracted, **kwargs): + """Mark document as favorited by a list of users.""" + if create and extracted: + for item in extracted: + models.DocumentFavorite.objects.create(document=self, user=item) + + +class UserDocumentAccessFactory(factory.django.DjangoModelFactory): + """Create fake document user accesses for testing.""" + + class Meta: + model = models.DocumentAccess + + document = factory.SubFactory(DocumentFactory) + user = factory.SubFactory(UserFactory) + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) + + +class TeamDocumentAccessFactory(factory.django.DjangoModelFactory): + """Create fake document team accesses for testing.""" + + class Meta: + model = models.DocumentAccess + + document = factory.SubFactory(DocumentFactory) + team = factory.Sequence(lambda n: f"team{n}") + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) + + +class TemplateFactory(factory.django.DjangoModelFactory): + """A factory to create templates""" + + class Meta: + model = models.Template + django_get_or_create = ("title",) + skip_postgeneration_save = True + + title = factory.Sequence(lambda n: f"template{n}") + is_public = factory.Faker("boolean") + + @factory.post_generation + def users(self, create, extracted, **kwargs): + """Add users to template from a given list of users with or without roles.""" + if create and extracted: + for item in extracted: + if isinstance(item, models.User): + UserTemplateAccessFactory(template=self, user=item) + else: + UserTemplateAccessFactory(template=self, user=item[0], role=item[1]) + + +class UserTemplateAccessFactory(factory.django.DjangoModelFactory): + """Create fake template user accesses for testing.""" + + class Meta: + model = models.TemplateAccess + + template = factory.SubFactory(TemplateFactory) + user = factory.SubFactory(UserFactory) + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) + + +class TeamTemplateAccessFactory(factory.django.DjangoModelFactory): + """Create fake template team accesses for testing.""" + + class Meta: + model = models.TemplateAccess + + template = factory.SubFactory(TemplateFactory) + team = factory.Sequence(lambda n: f"team{n}") + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) + + +class InvitationFactory(factory.django.DjangoModelFactory): + """A factory to create invitations for a user""" + + class Meta: + model = models.Invitation + + email = factory.Faker("email") + document = factory.SubFactory(DocumentFactory) + role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices]) + issuer = factory.SubFactory(UserFactory) diff --git a/submissions/devoteam/docs/src/backend/core/malware_detection.py b/submissions/devoteam/docs/src/backend/core/malware_detection.py new file mode 100644 index 00000000..9b1ef3a7 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/malware_detection.py @@ -0,0 +1,52 @@ +"""Malware detection callbacks""" + +import logging + +from django.core.files.storage import default_storage + +from lasuite.malware_detection.enums import ReportStatus + +from core.enums import DocumentAttachmentStatus +from core.models import Document + +logger = logging.getLogger(__name__) +security_logger = logging.getLogger("docs.security") + + +def malware_detection_callback(file_path, status, error_info, **kwargs): + """Malware detection callback""" + + if status == ReportStatus.SAFE: + logger.info("File %s is safe", file_path) + # Get existing metadata + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + head_resp = s3_client.head_object(Bucket=bucket_name, Key=file_path) + metadata = head_resp.get("Metadata", {}) + metadata.update({"status": DocumentAttachmentStatus.READY}) + # Update status in metadata + s3_client.copy_object( + Bucket=bucket_name, + CopySource={"Bucket": bucket_name, "Key": file_path}, + Key=file_path, + ContentType=head_resp.get("ContentType"), + Metadata=metadata, + MetadataDirective="REPLACE", + ) + return + + document_id = kwargs.get("document_id") + security_logger.warning( + "File %s for document %s is infected with malware. Error info: %s", + file_path, + document_id, + error_info, + ) + + # Remove the file from the document and change the status to unsafe + document = Document.objects.get(pk=document_id) + document.attachments.remove(file_path) + document.save(update_fields=["attachments"]) + + # Delete the file from the storage + default_storage.delete(file_path) diff --git a/submissions/devoteam/docs/src/backend/core/management/__init__.py b/submissions/devoteam/docs/src/backend/core/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/management/commands/__init__.py b/submissions/devoteam/docs/src/backend/core/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/management/commands/update_files_content_type_metadata.py b/submissions/devoteam/docs/src/backend/core/management/commands/update_files_content_type_metadata.py new file mode 100644 index 00000000..bb2e5253 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/management/commands/update_files_content_type_metadata.py @@ -0,0 +1,95 @@ +"""Management command updating the metadata for all the files in the MinIO bucket.""" + +from django.core.files.storage import default_storage +from django.core.management.base import BaseCommand + +import magic + +from core.models import Document + +# pylint: disable=too-many-locals, broad-exception-caught + + +class Command(BaseCommand): + """Update the metadata for all the files in the MinIO bucket.""" + + help = __doc__ + + def handle(self, *args, **options): + """Execute management command.""" + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + + mime_detector = magic.Magic(mime=True) + + documents = Document.objects.all() + self.stdout.write( + f"[INFO] Found {documents.count()} documents. Starting ContentType fix..." + ) + + for doc in documents: + doc_id_str = str(doc.id) + prefix = f"{doc_id_str}/attachments/" + self.stdout.write( + f"[INFO] Processing attachments under prefix '{prefix}' ..." + ) + + continuation_token = None + total_updated = 0 + + while True: + list_kwargs = {"Bucket": bucket_name, "Prefix": prefix} + if continuation_token: + list_kwargs["ContinuationToken"] = continuation_token + + response = s3_client.list_objects_v2(**list_kwargs) + + # If no objects found under this prefix, break out of the loop + if "Contents" not in response: + break + + for obj in response["Contents"]: + key = obj["Key"] + + # Skip if it's a folder + if key.endswith("/"): + continue + + try: + # Get existing metadata + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + + # Read first ~1KB for MIME detection + partial_obj = s3_client.get_object( + Bucket=bucket_name, Key=key, Range="bytes=0-1023" + ) + partial_data = partial_obj["Body"].read() + + # Detect MIME type + magic_mime_type = mime_detector.from_buffer(partial_data) + + # Update ContentType + s3_client.copy_object( + Bucket=bucket_name, + CopySource={"Bucket": bucket_name, "Key": key}, + Key=key, + ContentType=magic_mime_type, + Metadata=head_resp.get("Metadata", {}), + MetadataDirective="REPLACE", + ) + total_updated += 1 + + except Exception as exc: # noqa + self.stderr.write( + f"[ERROR] Could not update ContentType for {key}: {exc}" + ) + + if response.get("IsTruncated"): + continuation_token = response.get("NextContinuationToken") + else: + break + + if total_updated > 0: + self.stdout.write( + f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}." + ) diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0001_initial.py b/submissions/devoteam/docs/src/backend/core/migrations/0001_initial.py new file mode 100644 index 00000000..7f4d80ab --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0001_initial.py @@ -0,0 +1,552 @@ +# Generated by Django 5.0.3 on 2024-05-28 20:29 + +import uuid + +import django.contrib.auth.models +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import timezone_field.fields + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="Document", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="title")), + ( + "is_public", + models.BooleanField( + default=False, + help_text="Whether this document is public for anyone to use.", + verbose_name="public", + ), + ), + ], + options={ + "verbose_name": "Document", + "verbose_name_plural": "Documents", + "db_table": "impress_document", + "ordering": ("title",), + }, + ), + migrations.CreateModel( + name="Template", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="title")), + ( + "description", + models.TextField(blank=True, verbose_name="description"), + ), + ("code", models.TextField(blank=True, verbose_name="code")), + ("css", models.TextField(blank=True, verbose_name="css")), + ( + "is_public", + models.BooleanField( + default=False, + help_text="Whether this template is public for anyone to use.", + verbose_name="public", + ), + ), + ], + options={ + "verbose_name": "Template", + "verbose_name_plural": "Templates", + "db_table": "impress_template", + "ordering": ("title",), + }, + ), + migrations.CreateModel( + name="User", + fields=[ + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ( + "sub", + models.CharField( + blank=True, + help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.", + max_length=255, + null=True, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.", + regex="^[\\w.@+-]+\\Z", + ) + ], + verbose_name="sub", + ), + ), + ( + "email", + models.EmailField( + blank=True, + max_length=254, + null=True, + verbose_name="identity email address", + ), + ), + ( + "admin_email", + models.EmailField( + blank=True, + max_length=254, + null=True, + unique=True, + verbose_name="admin email address", + ), + ), + ( + "language", + models.CharField( + choices="(('en-us', 'English'), ('fr-fr', 'French'))", + default="en-us", + help_text="The language in which the user wants to see the interface.", + max_length=10, + verbose_name="language", + ), + ), + ( + "timezone", + timezone_field.fields.TimeZoneField( + choices_display="WITH_GMT_OFFSET", + default="UTC", + help_text="The timezone in which the user wants to see times.", + use_pytz=False, + ), + ), + ( + "is_device", + models.BooleanField( + default=False, + help_text="Whether the user is a device or a real user.", + verbose_name="device", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "db_table": "impress_user", + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="DocumentAccess", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("team", models.CharField(blank=True, max_length=100)), + ( + "role", + models.CharField( + choices=[ + ("reader", "Reader"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="accesses", + to="core.document", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Document/user relation", + "verbose_name_plural": "Document/user relations", + "db_table": "impress_document_access", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Invitation", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ( + "email", + models.EmailField(max_length=254, verbose_name="email address"), + ), + ( + "role", + models.CharField( + choices=[ + ("reader", "Reader"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to="core.document", + ), + ), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Document invitation", + "verbose_name_plural": "Document invitations", + "db_table": "impress_invitation", + }, + ), + migrations.CreateModel( + name="TemplateAccess", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("team", models.CharField(blank=True, max_length=100)), + ( + "role", + models.CharField( + choices=[ + ("reader", "Reader"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + ( + "template", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="accesses", + to="core.template", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Template/user relation", + "verbose_name_plural": "Template/user relations", + "db_table": "impress_template_access", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="documentaccess", + constraint=models.UniqueConstraint( + condition=models.Q(("user__isnull", False)), + fields=("user", "document"), + name="unique_document_user", + violation_error_message="This user is already in this document.", + ), + ), + migrations.AddConstraint( + model_name="documentaccess", + constraint=models.UniqueConstraint( + condition=models.Q(("team__gt", "")), + fields=("team", "document"), + name="unique_document_team", + violation_error_message="This team is already in this document.", + ), + ), + migrations.AddConstraint( + model_name="documentaccess", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("team", ""), ("user__isnull", False)), + models.Q(("team__gt", ""), ("user__isnull", True)), + _connector="OR", + ), + name="check_document_access_either_user_or_team", + violation_error_message="Either user or team must be set, not both.", + ), + ), + migrations.AddConstraint( + model_name="invitation", + constraint=models.UniqueConstraint( + fields=("email", "document"), name="email_and_document_unique_together" + ), + ), + migrations.AddConstraint( + model_name="templateaccess", + constraint=models.UniqueConstraint( + condition=models.Q(("user__isnull", False)), + fields=("user", "template"), + name="unique_template_user", + violation_error_message="This user is already in this template.", + ), + ), + migrations.AddConstraint( + model_name="templateaccess", + constraint=models.UniqueConstraint( + condition=models.Q(("team__gt", "")), + fields=("team", "template"), + name="unique_template_team", + violation_error_message="This team is already in this template.", + ), + ), + migrations.AddConstraint( + model_name="templateaccess", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("team", ""), ("user__isnull", False)), + models.Q(("team__gt", ""), ("user__isnull", True)), + _connector="OR", + ), + name="check_template_access_either_user_or_team", + violation_error_message="Either user or team must be set, not both.", + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0002_create_pg_trgm_extension.py b/submissions/devoteam/docs/src/backend/core/migrations/0002_create_pg_trgm_extension.py new file mode 100644 index 00000000..ff92a7d5 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0002_create_pg_trgm_extension.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.RunSQL( + "CREATE EXTENSION IF NOT EXISTS pg_trgm;", + reverse_sql="DROP EXTENSION IF EXISTS pg_trgm;", + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0003_document_link_reach_document_link_role_and_more.py b/submissions/devoteam/docs/src/backend/core/migrations/0003_document_link_reach_document_link_role_and_more.py new file mode 100644 index 00000000..02104a11 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0003_document_link_reach_document_link_role_and_more.py @@ -0,0 +1,114 @@ +# Generated by Django 5.1 on 2024-09-08 16:55 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0002_create_pg_trgm_extension"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="link_reach", + field=models.CharField( + choices=[ + ("restricted", "Restricted"), + ("authenticated", "Authenticated"), + ("public", "Public"), + ], + default="authenticated", + max_length=20, + ), + ), + migrations.AddField( + model_name="document", + name="link_role", + field=models.CharField( + choices=[("reader", "Reader"), ("editor", "Editor")], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="document", + name="is_public", + field=models.BooleanField(null=True), + ), + migrations.AlterField( + model_name="user", + name="language", + field=models.CharField( + choices="(('en-us', 'English'), ('fr-fr', 'French'))", + default="en-us", + help_text="The language in which the user wants to see the interface.", + max_length=10, + verbose_name="language", + ), + ), + migrations.CreateModel( + name="LinkTrace", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="link_traces", + to="core.document", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="link_traces", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Document/user link trace", + "verbose_name_plural": "Document/user link traces", + "db_table": "impress_link_trace", + "constraints": [ + models.UniqueConstraint( + fields=("user", "document"), + name="unique_link_trace_document_user", + violation_error_message="A link trace already exists for this document/user.", + ) + ], + }, + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0004_migrate_is_public_to_link_reach.py b/submissions/devoteam/docs/src/backend/core/migrations/0004_migrate_is_public_to_link_reach.py new file mode 100644 index 00000000..8c0b21e0 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0004_migrate_is_public_to_link_reach.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1 on 2024-09-08 17:04 +from django.db import migrations + + +def migrate_is_public_to_link_reach(apps, schema_editor): + """ + Forward migration: Migrate 'is_public' to 'link_reach'. + If is_public == True, set link_reach to 'public' + """ + Document = apps.get_model("core", "Document") + Document.objects.filter(is_public=True).update(link_reach="public") + + +def reverse_migrate_link_reach_to_is_public(apps, schema_editor): + """ + Reverse migration: Migrate 'link_reach' back to 'is_public'. + - If link_reach == 'public', set is_public to True + - Else set is_public to False + """ + Document = apps.get_model("core", "Document") + Document.objects.filter(link_reach="public").update(is_public=True) + Document.objects.filter(link_reach__in=["restricted", "authenticated"]).update( + is_public=False + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0003_document_link_reach_document_link_role_and_more"), + ] + + operations = [ + migrations.RunPython( + migrate_is_public_to_link_reach, reverse_migrate_link_reach_to_is_public + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0005_remove_document_is_public_alter_document_link_reach_and_more.py b/submissions/devoteam/docs/src/backend/core/migrations/0005_remove_document_is_public_alter_document_link_reach_and_more.py new file mode 100644 index 00000000..75436a68 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0005_remove_document_is_public_alter_document_link_reach_and_more.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1 on 2024-09-09 17:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0004_migrate_is_public_to_link_reach"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="title", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="title" + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0006_add_user_full_name_and_short_name.py b/submissions/devoteam/docs/src/backend/core/migrations/0006_add_user_full_name_and_short_name.py new file mode 100644 index 00000000..d3e8e78e --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0006_add_user_full_name_and_short_name.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.1 on 2024-09-29 03:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0005_remove_document_is_public_alter_document_link_reach_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="full_name", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="full name" + ), + ), + migrations.AddField( + model_name="user", + name="short_name", + field=models.CharField( + blank=True, max_length=20, null=True, verbose_name="short name" + ), + ), + migrations.AlterField( + model_name="user", + name="language", + field=models.CharField( + choices="(('en-us', 'English'), ('fr-fr', 'French'))", + default="en-us", + help_text="The language in which the user wants to see the interface.", + max_length=10, + verbose_name="language", + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0007_fix_users_duplicate.py b/submissions/devoteam/docs/src/backend/core/migrations/0007_fix_users_duplicate.py new file mode 100644 index 00000000..3eec88fe --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0007_fix_users_duplicate.py @@ -0,0 +1,128 @@ +# Generated by Django 5.1.1 on 2024-10-10 11:45 + +from django.db import migrations + +procedure = """ +DO $$ +DECLARE + user_email TEXT; +BEGIN + -- Step 1: Create a temporary table (without the unique constraint) + -- impress_document_access + DROP TABLE IF EXISTS impress_document_access_tmp; + CREATE TEMP TABLE impress_document_access_tmp AS + SELECT * FROM impress_document_access; + + -- impress_link_trace + DROP TABLE IF EXISTS impress_link_trace_tmp; + CREATE TEMP TABLE impress_link_trace_tmp AS + SELECT * FROM impress_link_trace; + + -- Step 2: Loop through each email that appears more than once + FOR user_email IN + SELECT email + FROM impress_user + GROUP BY email + HAVING COUNT(email) > 1 + LOOP + -- Step 3: Update user_id in the temporary table based on email + -- For impress_document_access + UPDATE impress_document_access_tmp + SET user_id = ( + SELECT id + FROM impress_user + WHERE email = user_email + LIMIT 1 + ) + WHERE user_id IN ( + SELECT id + FROM impress_user + WHERE email = user_email + ); + + -- For impress_link_trace + UPDATE impress_link_trace_tmp + SET user_id = ( + SELECT id + FROM impress_user + WHERE email = user_email + LIMIT 1 + ) + WHERE user_id IN ( + SELECT id + FROM impress_user + WHERE email = user_email + ); + + -- update impress_invitation + UPDATE impress_invitation + SET issuer_id = ( + SELECT id + FROM impress_user + WHERE email = user_email + LIMIT 1 + ) + WHERE issuer_id IN ( + SELECT id + FROM impress_user + WHERE email = user_email + ); + + DELETE FROM impress_user + WHERE id IN ( + SELECT id + FROM impress_user + WHERE email = user_email + ) + AND id != ( + SELECT id + FROM impress_user + WHERE email = user_email + LIMIT 1 + ); + + RAISE NOTICE 'Processed updates for email: %', user_email; + END LOOP; + + -- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id) + -- For impress_document_access + DELETE FROM impress_document_access_tmp a + USING impress_document_access_tmp b + WHERE a.ctid < b.ctid -- Keep one row + AND a.document_id = b.document_id + AND a.user_id = b.user_id; + + -- Step 5: Replace the original table with the cleaned-up temporary table + TRUNCATE TABLE impress_document_access; + + -- Insert cleaned-up data back into the original table + INSERT INTO impress_document_access + SELECT * FROM impress_document_access_tmp; + + -- For impress_link_trace + DELETE FROM impress_link_trace_tmp a + USING impress_link_trace_tmp b + WHERE a.ctid < b.ctid -- Keep one row + AND a.document_id = b.document_id + AND a.user_id = b.user_id; + + -- Step 5: Replace the original table with the cleaned-up temporary table + TRUNCATE TABLE impress_link_trace; + + -- Insert cleaned-up data back into the original table + INSERT INTO impress_link_trace + SELECT * FROM impress_link_trace_tmp; + + RAISE NOTICE 'Update and deduplication process completed.'; +END $$; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0006_add_user_full_name_and_short_name"), + ] + + operations = [ + migrations.RunSQL(procedure), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0008_alter_document_link_reach.py b/submissions/devoteam/docs/src/backend/core/migrations/0008_alter_document_link_reach.py new file mode 100644 index 00000000..334cdd48 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0008_alter_document_link_reach.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.2 on 2024-10-25 11:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0007_fix_users_duplicate"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="link_reach", + field=models.CharField( + choices=[ + ("restricted", "Restricted"), + ("authenticated", "Authenticated"), + ("public", "Public"), + ], + default="restricted", + max_length=20, + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0009_add_document_favorite.py b/submissions/devoteam/docs/src/backend/core/migrations/0009_add_document_favorite.py new file mode 100644 index 00000000..4e137b83 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0009_add_document_favorite.py @@ -0,0 +1,87 @@ +# Generated by Django 5.1.2 on 2024-11-08 07:59 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0008_alter_document_link_reach"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="language", + field=models.CharField( + choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", + default="en-us", + help_text="The language in which the user wants to see the interface.", + max_length=10, + verbose_name="language", + ), + ), + migrations.CreateModel( + name="DocumentFavorite", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="favorited_by_users", + to="core.document", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="favorite_documents", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Document favorite", + "verbose_name_plural": "Document favorites", + "db_table": "impress_document_favorite", + "constraints": [ + models.UniqueConstraint( + fields=("user", "document"), + name="unique_document_favorite_user", + violation_error_message="This document is already targeted by a favorite relation instance for the same user.", + ) + ], + }, + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0010_add_field_creator_to_document.py b/submissions/devoteam/docs/src/backend/core/migrations/0010_add_field_creator_to_document.py new file mode 100644 index 00000000..2d8c1c1b --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0010_add_field_creator_to_document.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.2 on 2024-11-09 11:36 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0009_add_document_favorite"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="creator", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name="documents_created", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="user", + name="language", + field=models.CharField( + choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", + default="en-us", + help_text="The language in which the user wants to see the interface.", + max_length=10, + verbose_name="language", + ), + ), + migrations.AlterField( + model_name="user", + name="sub", + field=models.CharField( + blank=True, + help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.", + max_length=255, + null=True, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.", + regex="^[\\w.@+-:]+\\Z", + ) + ], + verbose_name="sub", + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0011_populate_creator_field_and_make_it_required.py b/submissions/devoteam/docs/src/backend/core/migrations/0011_populate_creator_field_and_make_it_required.py new file mode 100644 index 00000000..62b1c9f2 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0011_populate_creator_field_and_make_it_required.py @@ -0,0 +1,61 @@ +# Generated by Django 5.1.2 on 2024-11-09 11:48 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db.models import F, ForeignKey, OuterRef, Q, Subquery + + +def set_creator_from_document_access(apps, schema_editor): + """ + Populate the `creator` field for existing Document records. + + This function assigns the `creator` field using the existing + DocumentAccess entries. We can be sure that all documents have at + least one user with "owner" role. If the document has several roles, + it should take the entry with the oldest date of creation. + + The update is performed using efficient bulk queries with Django's + Subquery and OuterRef to minimize database hits and ensure performance. + + Note: After running this migration, we quickly modify the schema to make + the `creator` field required. + """ + Document = apps.get_model("core", "Document") + DocumentAccess = apps.get_model("core", "DocumentAccess") + + # Update `creator` using the "owner" role + owner_subquery = ( + DocumentAccess.objects.filter( + document=OuterRef("pk"), + user__isnull=False, + role="owner", + ) + .order_by("created_at") + .values("user_id")[:1] + ) + + Document.objects.filter(creator__isnull=True).update( + creator=Subquery(owner_subquery) + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0010_add_field_creator_to_document"), + ] + + operations = [ + migrations.RunPython( + set_creator_from_document_access, reverse_code=migrations.RunPython.noop + ), + migrations.AlterField( + model_name="document", + name="creator", + field=ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, + related_name="documents_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0012_make_document_creator_and_invitation_issuer_optional.py b/submissions/devoteam/docs/src/backend/core/migrations/0012_make_document_creator_and_invitation_issuer_optional.py new file mode 100644 index 00000000..f10d2bc3 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0012_make_document_creator_and_invitation_issuer_optional.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.2 on 2024-11-30 22:23 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0011_populate_creator_field_and_make_it_required"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="creator", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name="documents_created", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="invitation", + name="issuer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="user", + name="language", + field=models.CharField( + choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", + default="en-us", + help_text="The language in which the user wants to see the interface.", + max_length=10, + verbose_name="language", + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py b/submissions/devoteam/docs/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py new file mode 100644 index 00000000..01b4dc6f --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.4 on 2025-01-25 08:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0012_make_document_creator_and_invitation_issuer_optional"), + ] + + operations = [ + migrations.RunSQL( + "CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;", + reverse_sql="DROP EXTENSION IF EXISTS fuzzystrmatch;", + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0014_add_tree_structure_to_documents.py b/submissions/devoteam/docs/src/backend/core/migrations/0014_add_tree_structure_to_documents.py new file mode 100644 index 00000000..c99473ab --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0014_add_tree_structure_to_documents.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.2 on 2024-12-07 09:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0013_activate_fuzzystrmatch_extension"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="depth", + field=models.PositiveIntegerField(default=0), + preserve_default=False, + ), + migrations.AddField( + model_name="document", + name="numchild", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="document", + name="path", + # Allow null values pending the next datamigration to populate the field + field=models.CharField( + db_collation="C", max_length=252, null=True, unique=True + ), + preserve_default=False, + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0015_set_path_on_existing_documents.py b/submissions/devoteam/docs/src/backend/core/migrations/0015_set_path_on_existing_documents.py new file mode 100644 index 00000000..1c3081fc --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0015_set_path_on_existing_documents.py @@ -0,0 +1,51 @@ +# Generated by Django 5.1.2 on 2024-12-07 10:33 + +from django.db import migrations, models + +from treebeard.numconv import NumConv + +ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +STEPLEN = 7 + + +def set_path_on_existing_documents(apps, schema_editor): + """ + Updates the `path` and `depth` fields for all existing Document records + to ensure valid materialized paths. + + This function assigns a unique `path` to each Document as a root node + + Note: After running this migration, we quickly modify the schema to make + the `path` field required as it should. + """ + Document = apps.get_model("core", "Document") + + # Iterate over all existing documents and make them root nodes + documents = Document.objects.order_by("created_at").values_list("id", flat=True) + numconv = NumConv(len(ALPHABET), ALPHABET) + + updates = [] + for i, pk in enumerate(documents): + key = numconv.int2str(i) + path = "{0}{1}".format(ALPHABET[0] * (STEPLEN - len(key)), key) + updates.append(Document(pk=pk, path=path, depth=1)) + + # Bulk update using the prepared updates list + Document.objects.bulk_update(updates, ["depth", "path"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0014_add_tree_structure_to_documents"), + ] + + operations = [ + migrations.RunPython( + set_path_on_existing_documents, reverse_code=migrations.RunPython.noop + ), + migrations.AlterField( + model_name="document", + name="path", + field=models.CharField(db_collation="C", max_length=252, unique=True), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0016_add_document_excerpt.py b/submissions/devoteam/docs/src/backend/core/migrations/0016_add_document_excerpt.py new file mode 100644 index 00000000..8a107789 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0016_add_document_excerpt.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.4 on 2024-12-18 08:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0015_set_path_on_existing_documents"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="excerpt", + field=models.TextField( + blank=True, max_length=300, null=True, verbose_name="excerpt" + ), + ), + migrations.AlterField( + model_name="user", + name="language", + field=models.CharField( + choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", + default="en-us", + help_text="The language in which the user wants to see the interface.", + max_length=10, + verbose_name="language", + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0017_add_fields_for_soft_delete.py b/submissions/devoteam/docs/src/backend/core/migrations/0017_add_fields_for_soft_delete.py new file mode 100644 index 00000000..00cd8a90 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0017_add_fields_for_soft_delete.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.4 on 2025-01-12 14:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0016_add_document_excerpt"), + ] + + operations = [ + migrations.AlterModelOptions( + name="document", + options={ + "ordering": ("path",), + "verbose_name": "Document", + "verbose_name_plural": "Documents", + }, + ), + migrations.AddField( + model_name="document", + name="ancestors_deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="document", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="user", + name="language", + field=models.CharField( + choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", + default="en-us", + help_text="The language in which the user wants to see the interface.", + max_length=10, + verbose_name="language", + ), + ), + migrations.AddConstraint( + model_name="document", + constraint=models.CheckConstraint( + condition=models.Q( + ("deleted_at__isnull", True), + ("deleted_at", models.F("ancestors_deleted_at")), + _connector="OR", + ), + name="check_deleted_at_matches_ancestors_deleted_at_when_set", + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0018_update_blank_title.py b/submissions/devoteam/docs/src/backend/core/migrations/0018_update_blank_title.py new file mode 100644 index 00000000..9576f53a --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0018_update_blank_title.py @@ -0,0 +1,24 @@ +from django.db import migrations + + +def update_titles_to_null(apps, schema_editor): + """ + If the titles are "Untitled document" or "Unbenanntes Dokument" or "Document sans titre" + we set them to Null + """ + Document = apps.get_model("core", "Document") + Document.objects.filter( + title__in=["Untitled document", "Unbenanntes Dokument", "Document sans titre"] + ).update(title=None) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0017_add_fields_for_soft_delete"), + ] + + operations = [ + migrations.RunPython( + update_titles_to_null, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0019_alter_user_language_default_to_null.py b/submissions/devoteam/docs/src/backend/core/migrations/0019_alter_user_language_default_to_null.py new file mode 100644 index 00000000..94b50a88 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0019_alter_user_language_default_to_null.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.5 on 2025-03-04 12:23 +from django.db import migrations, models + +import core.models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0018_update_blank_title"), + ] + + operations = [ + migrations.AlterModelManagers( + name="user", + managers=[ + ("objects", core.models.UserManager()), + ], + ), + migrations.AlterField( + model_name="user", + name="language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("fr-fr", "Français"), + ("de-de", "Deutsch"), + ], + default=None, + help_text="The language in which the user wants to see the interface.", + max_length=10, + null=True, + verbose_name="language", + ), + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0020_remove_is_public_add_field_attachments_and_duplicated_from.py b/submissions/devoteam/docs/src/backend/core/migrations/0020_remove_is_public_add_field_attachments_and_duplicated_from.py new file mode 100644 index 00000000..cc8700b0 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0020_remove_is_public_add_field_attachments_and_duplicated_from.py @@ -0,0 +1,77 @@ +# Generated by Django 5.1.4 on 2025-01-18 11:53 +import re + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.core.files.storage import default_storage +from django.db import migrations, models + +from botocore.exceptions import ClientError + +import core.models +from core.utils import extract_attachments + + +def populate_attachments_on_all_documents(apps, schema_editor): + """Populate "attachments" field on all existing documents in the database.""" + Document = apps.get_model("core", "Document") + + for document in Document.objects.all(): + try: + response = default_storage.connection.meta.client.get_object( + Bucket=default_storage.bucket_name, Key=f"{document.pk!s}/file" + ) + except (FileNotFoundError, ClientError): + pass + else: + content = response["Body"].read().decode("utf-8") + document.attachments = extract_attachments(content) + document.save(update_fields=["attachments"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0019_alter_user_language_default_to_null"), + ] + + operations = [ + # v2.0.0 was released so we can now remove BC field "is_public" + migrations.RemoveField( + model_name="document", + name="is_public", + ), + migrations.AlterModelManagers( + name="user", + managers=[ + ("objects", core.models.UserManager()), + ], + ), + migrations.AddField( + model_name="document", + name="attachments", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=255), + blank=True, + default=list, + editable=False, + null=True, + size=None, + ), + ), + migrations.AddField( + model_name="document", + name="duplicated_from", + field=models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="duplicates", + to="core.document", + ), + ), + migrations.RunPython( + populate_attachments_on_all_documents, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/0021_activate_unaccent_extension.py b/submissions/devoteam/docs/src/backend/core/migrations/0021_activate_unaccent_extension.py new file mode 100644 index 00000000..b3bd5ec4 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/migrations/0021_activate_unaccent_extension.py @@ -0,0 +1,10 @@ +from django.contrib.postgres.operations import UnaccentExtension +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from"), + ] + + operations = [UnaccentExtension()] diff --git a/submissions/devoteam/docs/src/backend/core/migrations/__init__.py b/submissions/devoteam/docs/src/backend/core/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/models.py b/submissions/devoteam/docs/src/backend/core/models.py new file mode 100644 index 00000000..e9880f52 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/models.py @@ -0,0 +1,1336 @@ +""" +Declare and configure the models for the impress core application +""" +# pylint: disable=too-many-lines + +import hashlib +import smtplib +import uuid +from collections import defaultdict +from datetime import timedelta +from logging import getLogger + +from django.conf import settings +from django.contrib.auth import models as auth_models +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.postgres.fields import ArrayField +from django.contrib.sites.models import Site +from django.core import mail, validators +from django.core.cache import cache +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.core.mail import send_mail +from django.db import models, transaction +from django.db.models.functions import Left, Length +from django.template.loader import render_to_string +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.translation import get_language, override +from django.utils.translation import gettext_lazy as _ + +from botocore.exceptions import ClientError +from rest_framework.exceptions import ValidationError +from timezone_field import TimeZoneField +from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet + +logger = getLogger(__name__) + + +def get_trashbin_cutoff(): + """ + Calculate the cutoff datetime for soft-deleted items based on the retention policy. + + The function returns the current datetime minus the number of days specified in + the TRASHBIN_CUTOFF_DAYS setting, indicating the oldest date for items that can + remain in the trash bin. + + Returns: + datetime: The cutoff datetime for soft-deleted items. + """ + return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS) + + +class LinkRoleChoices(models.TextChoices): + """Defines the possible roles a link can offer on a document.""" + + READER = "reader", _("Reader") # Can read + EDITOR = "editor", _("Editor") # Can read and edit + + +class RoleChoices(models.TextChoices): + """Defines the possible roles a user can have in a resource.""" + + READER = "reader", _("Reader") # Can read + EDITOR = "editor", _("Editor") # Can read and edit + ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share + OWNER = "owner", _("Owner") + + +PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER] + + +class LinkReachChoices(models.TextChoices): + """Defines types of access for links""" + + RESTRICTED = ( + "restricted", + _("Restricted"), + ) # Only users with a specific access can read/edit the document + AUTHENTICATED = ( + "authenticated", + _("Authenticated"), + ) # Any authenticated user can access the document + PUBLIC = "public", _("Public") # Even anonymous users can access the document + + @classmethod + def get_select_options(cls, ancestors_links): + """ + Determines the valid select options for link reach and link role depending on the + list of ancestors' link reach/role. + + Args: + ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys + representing the reach and role of ancestors links. + + Returns: + Dictionary mapping possible reach levels to their corresponding possible roles. + """ + # If no ancestors, return all options + if not ancestors_links: + return dict.fromkeys(cls.values, LinkRoleChoices.values) + + # Initialize result with all possible reaches and role options as sets + result = {reach: set(LinkRoleChoices.values) for reach in cls.values} + + # Group roles by reach level + reach_roles = defaultdict(set) + for link in ancestors_links: + reach_roles[link["link_reach"]].add(link["link_role"]) + + # Apply constraints based on ancestor links + if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]: + result[cls.RESTRICTED].discard(LinkRoleChoices.READER) + + if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]: + result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) + result.pop(cls.RESTRICTED, None) + elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]: + result[cls.RESTRICTED].discard(LinkRoleChoices.READER) + + if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]: + result[cls.PUBLIC].discard(LinkRoleChoices.READER) + result.pop(cls.AUTHENTICATED, None) + result.pop(cls.RESTRICTED, None) + elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]: + result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) + result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER) + + # Convert roles sets to lists while maintaining the order from LinkRoleChoices + for reach, roles in result.items(): + result[reach] = [role for role in LinkRoleChoices.values if role in roles] + + return result + + +class DuplicateEmailError(Exception): + """Raised when an email is already associated with a pre-existing user.""" + + def __init__(self, message=None, email=None): + """Set message and email to describe the exception.""" + self.message = message + self.email = email + super().__init__(self.message) + + +class BaseModel(models.Model): + """ + Serves as an abstract base model for other models, ensuring that records are validated + before saving as Django doesn't do it by default. + + Includes fields common to all models: a UUID primary key and creation/update timestamps. + """ + + id = models.UUIDField( + verbose_name=_("id"), + help_text=_("primary key for the record as UUID"), + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + created_at = models.DateTimeField( + verbose_name=_("created on"), + help_text=_("date and time at which a record was created"), + auto_now_add=True, + editable=False, + ) + updated_at = models.DateTimeField( + verbose_name=_("updated on"), + help_text=_("date and time at which a record was last updated"), + auto_now=True, + editable=False, + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + """Call `full_clean` before saving.""" + self.full_clean() + super().save(*args, **kwargs) + + +class UserManager(auth_models.UserManager): + """Custom manager for User model with additional methods.""" + + def get_user_by_sub_or_email(self, sub, email): + """Fetch existing user by sub or email.""" + try: + return self.get(sub=sub) + except self.model.DoesNotExist as err: + if not email: + return None + + if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION: + try: + return self.get(email=email) + except self.model.DoesNotExist: + pass + elif ( + self.filter(email=email).exists() + and not settings.OIDC_ALLOW_DUPLICATE_EMAILS + ): + raise DuplicateEmailError( + _( + "We couldn't find a user with this sub but the email is already " + "associated with a registered user." + ) + ) from err + return None + + +class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): + """User model to work with OIDC only authentication.""" + + sub_validator = validators.RegexValidator( + regex=r"^[\w.@+-:]+\Z", + message=_( + "Enter a valid sub. This value may contain only letters, " + "numbers, and @/./+/-/_/: characters." + ), + ) + + sub = models.CharField( + _("sub"), + help_text=_( + "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." + ), + max_length=255, + unique=True, + validators=[sub_validator], + blank=True, + null=True, + ) + + full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True) + short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True) + + email = models.EmailField(_("identity email address"), blank=True, null=True) + + # Unlike the "email" field which stores the email coming from the OIDC token, this field + # stores the email used by staff users to login to the admin site + admin_email = models.EmailField( + _("admin email address"), unique=True, blank=True, null=True + ) + + language = models.CharField( + max_length=10, + choices=settings.LANGUAGES, + default=None, + verbose_name=_("language"), + help_text=_("The language in which the user wants to see the interface."), + null=True, + blank=True, + ) + timezone = TimeZoneField( + choices_display="WITH_GMT_OFFSET", + use_pytz=False, + default=settings.TIME_ZONE, + help_text=_("The timezone in which the user wants to see times."), + ) + is_device = models.BooleanField( + _("device"), + default=False, + help_text=_("Whether the user is a device or a real user."), + ) + is_staff = models.BooleanField( + _("staff status"), + default=False, + help_text=_("Whether the user can log into this admin site."), + ) + is_active = models.BooleanField( + _("active"), + default=True, + help_text=_( + "Whether this user should be treated as active. " + "Unselect this instead of deleting accounts." + ), + ) + + objects = UserManager() + + USERNAME_FIELD = "admin_email" + REQUIRED_FIELDS = [] + + class Meta: + db_table = "impress_user" + verbose_name = _("user") + verbose_name_plural = _("users") + + def __str__(self): + return self.email or self.admin_email or str(self.id) + + def save(self, *args, **kwargs): + """ + If it's a new user, give its user access to the documents to which s.he was invited. + """ + is_adding = self._state.adding + super().save(*args, **kwargs) + + if is_adding: + self._convert_valid_invitations() + + def _convert_valid_invitations(self): + """ + Convert valid invitations to document accesses. + Expired invitations are ignored. + """ + valid_invitations = Invitation.objects.filter( + email=self.email, + created_at__gte=( + timezone.now() + - timedelta(seconds=settings.INVITATION_VALIDITY_DURATION) + ), + ).select_related("document") + + if not valid_invitations.exists(): + return + + DocumentAccess.objects.bulk_create( + [ + DocumentAccess( + user=self, document=invitation.document, role=invitation.role + ) + for invitation in valid_invitations + ] + ) + + # Set creator of documents if not yet set (e.g. documents created via server-to-server API) + document_ids = [invitation.document_id for invitation in valid_invitations] + Document.objects.filter(id__in=document_ids, creator__isnull=True).update( + creator=self + ) + + valid_invitations.delete() + + def email_user(self, subject, message, from_email=None, **kwargs): + """Email this user.""" + if not self.email: + raise ValueError("User has no email address.") + mail.send_mail(subject, message, from_email, [self.email], **kwargs) + + @cached_property + def teams(self): + """ + Get list of teams in which the user is, as a list of strings. + Must be cached if retrieved remotely. + """ + return [] + + +class BaseAccess(BaseModel): + """Base model for accesses to handle resources.""" + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + ) + team = models.CharField(max_length=100, blank=True) + role = models.CharField( + max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER + ) + + class Meta: + abstract = True + + def _get_roles(self, resource, user): + """ + Get the roles a user has on a resource. + """ + roles = [] + if user.is_authenticated: + teams = user.teams + try: + roles = self.user_roles or [] + except AttributeError: + try: + roles = resource.accesses.filter( + models.Q(user=user) | models.Q(team__in=teams), + ).values_list("role", flat=True) + except (self._meta.model.DoesNotExist, IndexError): + roles = [] + + return roles + + def _get_abilities(self, resource, user): + """ + Compute and return abilities for a given user taking into account + the current state of the object. + """ + roles = self._get_roles(resource, user) + + is_owner_or_admin = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + if self.role == RoleChoices.OWNER: + can_delete = ( + RoleChoices.OWNER in roles + and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1 + ) + set_role_to = ( + [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] + if can_delete + else [] + ) + else: + can_delete = is_owner_or_admin + set_role_to = [] + if RoleChoices.OWNER in roles: + set_role_to.append(RoleChoices.OWNER) + if is_owner_or_admin: + set_role_to.extend( + [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] + ) + + # Remove the current role as we don't want to propose it as an option + try: + set_role_to.remove(self.role) + except ValueError: + pass + + return { + "destroy": can_delete, + "update": bool(set_role_to), + "partial_update": bool(set_role_to), + "retrieve": bool(roles), + "set_role_to": set_role_to, + } + + +class DocumentQuerySet(MP_NodeQuerySet): + """ + Custom queryset for the Document model, providing additional methods + to filter documents based on user permissions. + """ + + def readable_per_se(self, user): + """ + Filters the queryset to return documents on which the given user has + direct access, team access or link access. This will not return all the + documents that a user can read because it can be obtained via an ancestor. + :param user: The user for whom readable documents are to be fetched. + :return: A queryset of documents for which the user has direct access, + team access or link access. + """ + if user.is_authenticated: + return self.filter( + models.Q(accesses__user=user) + | models.Q(accesses__team__in=user.teams) + | ~models.Q(link_reach=LinkReachChoices.RESTRICTED) + ) + + return self.filter(link_reach=LinkReachChoices.PUBLIC) + + +class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)): + """ + Custom manager for the Document model, enabling the use of the custom + queryset methods directly from the model manager. + """ + + def get_queryset(self): + """Sets the custom queryset as the default.""" + return self._queryset_class(self.model).order_by("path") + + +class Document(MP_Node, BaseModel): + """Pad document carrying the content.""" + + title = models.CharField(_("title"), max_length=255, null=True, blank=True) + excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True) + link_reach = models.CharField( + max_length=20, + choices=LinkReachChoices.choices, + default=LinkReachChoices.RESTRICTED, + ) + link_role = models.CharField( + max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER + ) + creator = models.ForeignKey( + User, + on_delete=models.RESTRICT, + related_name="documents_created", + blank=True, + null=True, + ) + deleted_at = models.DateTimeField(null=True, blank=True) + ancestors_deleted_at = models.DateTimeField(null=True, blank=True) + duplicated_from = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + related_name="duplicates", + editable=False, + blank=True, + null=True, + ) + attachments = ArrayField( + models.CharField(max_length=255), + default=list, + editable=False, + blank=True, + null=True, + ) + + _content = None + + # Tree structure + alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + steplen = 7 # nb siblings max: 3,521,614,606,208 + node_order_by = [] # Manual ordering + + path = models.CharField(max_length=7 * 36, unique=True, db_collation="C") + + objects = DocumentManager() + + class Meta: + db_table = "impress_document" + ordering = ("path",) + verbose_name = _("Document") + verbose_name_plural = _("Documents") + constraints = [ + models.CheckConstraint( + check=( + models.Q(deleted_at__isnull=True) + | models.Q(deleted_at=models.F("ancestors_deleted_at")) + ), + name="check_deleted_at_matches_ancestors_deleted_at_when_set", + ), + ] + + def __str__(self): + return str(self.title) if self.title else str(_("Untitled Document")) + + def save(self, *args, **kwargs): + """Write content to object storage only if _content has changed.""" + super().save(*args, **kwargs) + + if self._content: + file_key = self.file_key + bytes_content = self._content.encode("utf-8") + + # Attempt to directly check if the object exists using the storage client. + try: + response = default_storage.connection.meta.client.head_object( + Bucket=default_storage.bucket_name, Key=file_key + ) + except ClientError as excpt: + # If the error is a 404, the object doesn't exist, so we should create it. + if excpt.response["Error"]["Code"] == "404": + has_changed = True + else: + raise + else: + # Compare the existing ETag with the MD5 hash of the new content. + has_changed = ( + response["ETag"].strip('"') + != hashlib.md5(bytes_content).hexdigest() # noqa: S324 + ) + + if has_changed: + content_file = ContentFile(bytes_content) + default_storage.save(file_key, content_file) + + @property + def key_base(self): + """Key base of the location where the document is stored in object storage.""" + if not self.pk: + raise RuntimeError( + "The document instance must be saved before requesting a storage key." + ) + return str(self.pk) + + @property + def file_key(self): + """Key of the object storage file to which the document content is stored""" + return f"{self.key_base}/file" + + @property + def content(self): + """Return the json content from object storage if available""" + if self._content is None and self.id: + try: + response = self.get_content_response() + except (FileNotFoundError, ClientError): + pass + else: + self._content = response["Body"].read().decode("utf-8") + return self._content + + @content.setter + def content(self, content): + """Cache the content, don't write to object storage yet""" + if not isinstance(content, str): + raise ValueError("content should be a string.") + + self._content = content + + def get_content_response(self, version_id=""): + """Get the content in a specific version of the document""" + params = { + "Bucket": default_storage.bucket_name, + "Key": self.file_key, + } + if version_id: + params["VersionId"] = version_id + return default_storage.connection.meta.client.get_object(**params) + + def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None): + """Get document versions from object storage with pagination and starting conditions""" + # /!\ Trick here /!\ + # The "KeyMarker" and "VersionIdMarker" fields must either be both set or both not set. + # The error we get otherwise is not helpful at all. + markers = {} + if from_version_id: + markers.update( + {"KeyMarker": self.file_key, "VersionIdMarker": from_version_id} + ) + + real_page_size = ( + min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE) + if page_size + else settings.DOCUMENT_VERSIONS_PAGE_SIZE + ) + + response = default_storage.connection.meta.client.list_object_versions( + Bucket=default_storage.bucket_name, + Prefix=self.file_key, + # compensate the latest version that we exclude below and get one more to + # know if there are more pages + MaxKeys=real_page_size + 2, + **markers, + ) + + min_last_modified = min_datetime or self.created_at + versions = [ + { + key_snake: version[key_camel] + for key_snake, key_camel in [ + ("etag", "ETag"), + ("is_latest", "IsLatest"), + ("last_modified", "LastModified"), + ("version_id", "VersionId"), + ] + } + for version in response.get("Versions", []) + if version["LastModified"] >= min_last_modified + and version["IsLatest"] is False + ] + results = versions[:real_page_size] + + count = len(results) + if count == len(versions): + is_truncated = False + next_version_id_marker = "" + else: + is_truncated = True + next_version_id_marker = versions[count - 1]["version_id"] + + return { + "next_version_id_marker": next_version_id_marker, + "is_truncated": is_truncated, + "versions": results, + "count": count, + } + + def delete_version(self, version_id): + """Delete a version from object storage given its version id""" + return default_storage.connection.meta.client.delete_object( + Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id + ) + + def get_nb_accesses_cache_key(self): + """Generate a unique cache key for each document.""" + return f"document_{self.id!s}_nb_accesses" + + def get_nb_accesses(self): + """ + Calculate the number of accesses: + - directly attached to the document + - attached to any of the document's ancestors + """ + cache_key = self.get_nb_accesses_cache_key() + nb_accesses = cache.get(cache_key) + + if nb_accesses is None: + nb_accesses = ( + DocumentAccess.objects.filter(document=self).count(), + DocumentAccess.objects.filter( + document__path=Left( + models.Value(self.path), Length("document__path") + ), + document__ancestors_deleted_at__isnull=True, + ).count(), + ) + cache.set(cache_key, nb_accesses) + + return nb_accesses + + @property + def nb_accesses_direct(self): + """Returns the number of accesses related to the document or one of its ancestors.""" + return self.get_nb_accesses()[0] + + @property + def nb_accesses_ancestors(self): + """Returns the number of accesses related to the document or one of its ancestors.""" + return self.get_nb_accesses()[1] + + def invalidate_nb_accesses_cache(self): + """ + Invalidate the cache for number of accesses, including on affected descendants. + Args: + path: can optionally be passed as argument (useful when invalidating cache for a + document we just deleted) + """ + + for document in Document.objects.filter(path__startswith=self.path).only("id"): + cache_key = document.get_nb_accesses_cache_key() + cache.delete(cache_key) + + def get_roles(self, user): + """Return the roles a user has on a document.""" + if not user.is_authenticated: + return [] + + try: + roles = self.user_roles or [] + except AttributeError: + try: + roles = DocumentAccess.objects.filter( + models.Q(user=user) | models.Q(team__in=user.teams), + document__path=Left( + models.Value(self.path), Length("document__path") + ), + ).values_list("role", flat=True) + except (models.ObjectDoesNotExist, IndexError): + roles = [] + return roles + + def get_links_definitions(self, ancestors_links): + """Get links reach/role definitions for the current document and its ancestors.""" + + links_definitions = defaultdict(set) + links_definitions[self.link_reach].add(self.link_role) + + # Merge ancestor link definitions + for ancestor in ancestors_links: + links_definitions[ancestor["link_reach"]].add(ancestor["link_role"]) + + return dict(links_definitions) # Convert defaultdict back to a normal dict + + def compute_ancestors_links(self, user): + """ + Compute the ancestors links for the current document up to the highest readable ancestor. + """ + ancestors = ( + (self.get_ancestors() | self._meta.model.objects.filter(pk=self.pk)) + .filter(ancestors_deleted_at__isnull=True) + .order_by("path") + ) + highest_readable = ancestors.readable_per_se(user).only("depth").first() + + if highest_readable is None: + return [] + + ancestors_links = [] + paths_links_mapping = {} + for ancestor in ancestors.filter(depth__gte=highest_readable.depth): + ancestors_links.append( + {"link_reach": ancestor.link_reach, "link_role": ancestor.link_role} + ) + paths_links_mapping[ancestor.path] = ancestors_links.copy() + + ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], []) + + return ancestors_links + + def get_abilities(self, user, ancestors_links=None): + """ + Compute and return abilities for a given user on the document. + """ + if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False): + ancestors_links = [] + elif ancestors_links is None: + ancestors_links = self.compute_ancestors_links(user=user) + + roles = set( + self.get_roles(user) + ) # at this point only roles based on specific access + + # Characteristics that are based only on specific access + is_owner = RoleChoices.OWNER in roles + is_deleted = self.ancestors_deleted_at and not is_owner + is_owner_or_admin = (is_owner or RoleChoices.ADMIN in roles) and not is_deleted + + # Compute access roles before adding link roles because we don't + # want anonymous users to access versions (we wouldn't know from + # which date to allow them anyway) + # Anonymous users should also not see document accesses + has_access_role = bool(roles) and not is_deleted + can_update_from_access = ( + is_owner_or_admin or RoleChoices.EDITOR in roles + ) and not is_deleted + + # Add roles provided by the document link, taking into account its ancestors + links_definitions = self.get_links_definitions(ancestors_links) + public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set()) + authenticated_roles = ( + links_definitions.get(LinkReachChoices.AUTHENTICATED, set()) + if user.is_authenticated + else set() + ) + roles = roles | public_roles | authenticated_roles + + can_get = bool(roles) and not is_deleted + can_update = ( + is_owner_or_admin or RoleChoices.EDITOR in roles + ) and not is_deleted + + ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM + ai_access = any( + [ + ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update, + ai_allow_reach_from == LinkReachChoices.AUTHENTICATED + and user.is_authenticated + and can_update, + ai_allow_reach_from == LinkReachChoices.RESTRICTED + and can_update_from_access, + ] + ) + + return { + "accesses_manage": is_owner_or_admin, + "accesses_view": has_access_role, + "ai_transform": ai_access, + "ai_translate": ai_access, + "attachment_upload": can_update, + "media_check": can_get, + "children_list": can_get, + "children_create": can_update and user.is_authenticated, + "collaboration_auth": can_get, + "cors_proxy": can_get, + "descendants": can_get, + "destroy": is_owner, + "duplicate": can_get, + "favorite": can_get and user.is_authenticated, + "link_configuration": is_owner_or_admin, + "invite_owner": is_owner, + "move": is_owner_or_admin and not self.ancestors_deleted_at, + "partial_update": can_update, + "restore": is_owner, + "retrieve": can_get, + "media_auth": can_get, + "link_select_options": LinkReachChoices.get_select_options(ancestors_links), + "tree": can_get, + "update": can_update, + "versions_destroy": is_owner_or_admin, + "versions_list": has_access_role, + "versions_retrieve": has_access_role, + } + + def send_email(self, subject, emails, context=None, language=None): + """Generate and send email from a template.""" + context = context or {} + domain = Site.objects.get_current().domain + language = language or get_language() + context.update( + { + "brandname": settings.EMAIL_BRAND_NAME, + "document": self, + "domain": domain, + "link": f"{domain}/docs/{self.id}/", + "document_title": self.title or str(_("Untitled Document")), + "logo_img": settings.EMAIL_LOGO_IMG, + } + ) + + with override(language): + msg_html = render_to_string("mail/html/invitation.html", context) + msg_plain = render_to_string("mail/text/invitation.txt", context) + subject = str(subject) # Force translation + + try: + send_mail( + subject.capitalize(), + msg_plain, + settings.EMAIL_FROM, + emails, + html_message=msg_html, + fail_silently=False, + ) + except smtplib.SMTPException as exception: + logger.error("invitation to %s was not sent: %s", emails, exception) + + def send_invitation_email(self, email, role, sender, language=None): + """Method allowing a user to send an email invitation to another user for a document.""" + language = language or get_language() + role = RoleChoices(role).label + sender_name = sender.full_name or sender.email + sender_name_email = ( + f"{sender.full_name:s} ({sender.email})" + if sender.full_name + else sender.email + ) + + with override(language): + context = { + "title": _("{name} shared a document with you!").format( + name=sender_name + ), + "message": _( + '{name} invited you with the role "{role}" on the following document:' + ).format(name=sender_name_email, role=role.lower()), + } + subject = ( + context["title"] + if not self.title + else _("{name} shared a document with you: {title}").format( + name=sender_name, title=self.title + ) + ) + + self.send_email(subject, [email], context, language) + + @transaction.atomic + def soft_delete(self): + """ + Soft delete the document, marking the deletion on descendants. + We still keep the .delete() method untouched for programmatic purposes. + """ + if ( + self._meta.model.objects.filter( + models.Q(deleted_at__isnull=False) + | models.Q(ancestors_deleted_at__isnull=False), + pk=self.pk, + ).exists() + or self.get_ancestors().filter(deleted_at__isnull=False).exists() + ): + raise RuntimeError( + "This document is already deleted or has deleted ancestors." + ) + + self.ancestors_deleted_at = self.deleted_at = timezone.now() + self.save() + self.invalidate_nb_accesses_cache() + + if self.depth > 1: + self._meta.model.objects.filter(pk=self.get_parent().pk).update( + numchild=models.F("numchild") - 1 + ) + + # Mark all descendants as soft deleted + self.get_descendants().filter(ancestors_deleted_at__isnull=True).update( + ancestors_deleted_at=self.ancestors_deleted_at + ) + + @transaction.atomic + def restore(self): + """Cancelling a soft delete with checks.""" + # This should not happen + if self._meta.model.objects.filter( + pk=self.pk, deleted_at__isnull=True + ).exists(): + raise RuntimeError("This document is not deleted.") + + if self.deleted_at < get_trashbin_cutoff(): + raise RuntimeError( + "This document was permanently deleted and cannot be restored." + ) + + # save the current deleted_at value to exclude it from the descendants update + current_deleted_at = self.deleted_at + + # Restore the current document + self.deleted_at = None + + # Calculate the minimum `deleted_at` among all ancestors + ancestors_deleted_at = ( + self.get_ancestors() + .filter(deleted_at__isnull=False) + .order_by("deleted_at") + .values_list("deleted_at", flat=True) + .first() + ) + self.ancestors_deleted_at = ancestors_deleted_at + self.save(update_fields=["deleted_at", "ancestors_deleted_at"]) + self.invalidate_nb_accesses_cache() + + self.get_descendants().exclude( + models.Q(deleted_at__isnull=False) + | models.Q(ancestors_deleted_at__lt=current_deleted_at) + ).update(ancestors_deleted_at=self.ancestors_deleted_at) + + if self.depth > 1: + self._meta.model.objects.filter(pk=self.get_parent().pk).update( + numchild=models.F("numchild") + 1 + ) + + +class LinkTrace(BaseModel): + """ + Relation model to trace accesses to a document via a link by a logged-in user. + This is necessary to show the document in the user's list of documents even + though the user does not have a role on the document. + """ + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="link_traces", + ) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces") + + class Meta: + db_table = "impress_link_trace" + verbose_name = _("Document/user link trace") + verbose_name_plural = _("Document/user link traces") + constraints = [ + models.UniqueConstraint( + fields=["user", "document"], + name="unique_link_trace_document_user", + violation_error_message=_( + "A link trace already exists for this document/user." + ), + ), + ] + + def __str__(self): + return f"{self.user!s} trace on document {self.document!s}" + + +class DocumentFavorite(BaseModel): + """Relation model to store a user's favorite documents.""" + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="favorited_by_users", + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="favorite_documents" + ) + + class Meta: + db_table = "impress_document_favorite" + verbose_name = _("Document favorite") + verbose_name_plural = _("Document favorites") + constraints = [ + models.UniqueConstraint( + fields=["user", "document"], + name="unique_document_favorite_user", + violation_error_message=_( + "This document is already targeted by a favorite relation instance " + "for the same user." + ), + ), + ] + + def __str__(self): + return f"{self.user!s} favorite on document {self.document!s}" + + +class DocumentAccess(BaseAccess): + """Relation model to give access to a document for a user or a team with a role.""" + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="accesses", + ) + + class Meta: + db_table = "impress_document_access" + ordering = ("-created_at",) + verbose_name = _("Document/user relation") + verbose_name_plural = _("Document/user relations") + constraints = [ + models.UniqueConstraint( + fields=["user", "document"], + condition=models.Q(user__isnull=False), # Exclude null users + name="unique_document_user", + violation_error_message=_("This user is already in this document."), + ), + models.UniqueConstraint( + fields=["team", "document"], + condition=models.Q(team__gt=""), # Exclude empty string teams + name="unique_document_team", + violation_error_message=_("This team is already in this document."), + ), + models.CheckConstraint( + check=models.Q(user__isnull=False, team="") + | models.Q(user__isnull=True, team__gt=""), + name="check_document_access_either_user_or_team", + violation_error_message=_("Either user or team must be set, not both."), + ), + ] + + def __str__(self): + return f"{self.user!s} is {self.role:s} in document {self.document!s}" + + def save(self, *args, **kwargs): + """Override save to clear the document's cache for number of accesses.""" + super().save(*args, **kwargs) + self.document.invalidate_nb_accesses_cache() + + def delete(self, *args, **kwargs): + """Override delete to clear the document's cache for number of accesses.""" + super().delete(*args, **kwargs) + self.document.invalidate_nb_accesses_cache() + + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the document access. + """ + roles = self._get_roles(self.document, user) + is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES))) + if self.role == RoleChoices.OWNER: + can_delete = ( + RoleChoices.OWNER in roles + and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1 + ) + set_role_to = ( + [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] + if can_delete + else [] + ) + else: + can_delete = is_owner_or_admin + set_role_to = [] + if RoleChoices.OWNER in roles: + set_role_to.append(RoleChoices.OWNER) + if is_owner_or_admin: + set_role_to.extend( + [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] + ) + + # Remove the current role as we don't want to propose it as an option + try: + set_role_to.remove(self.role) + except ValueError: + pass + + return { + "destroy": can_delete, + "update": bool(set_role_to) and is_owner_or_admin, + "partial_update": bool(set_role_to) and is_owner_or_admin, + "retrieve": self.user and self.user.id == user.id or is_owner_or_admin, + "set_role_to": set_role_to, + } + + +class Template(BaseModel): + """HTML and CSS code used for formatting the print around the MarkDown body.""" + + title = models.CharField(_("title"), max_length=255) + description = models.TextField(_("description"), blank=True) + code = models.TextField(_("code"), blank=True) + css = models.TextField(_("css"), blank=True) + is_public = models.BooleanField( + _("public"), + default=False, + help_text=_("Whether this template is public for anyone to use."), + ) + + class Meta: + db_table = "impress_template" + ordering = ("title",) + verbose_name = _("Template") + verbose_name_plural = _("Templates") + + def __str__(self): + return self.title + + def get_roles(self, user): + """Return the roles a user has on a resource as an iterable.""" + if not user.is_authenticated: + return [] + + try: + roles = self.user_roles or [] + except AttributeError: + try: + roles = self.accesses.filter( + models.Q(user=user) | models.Q(team__in=user.teams), + ).values_list("role", flat=True) + except (models.ObjectDoesNotExist, IndexError): + roles = [] + return roles + + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the template. + """ + roles = self.get_roles(user) + is_owner_or_admin = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + can_get = self.is_public or bool(roles) + can_update = is_owner_or_admin or RoleChoices.EDITOR in roles + + return { + "destroy": RoleChoices.OWNER in roles, + "generate_document": can_get, + "accesses_manage": is_owner_or_admin, + "update": can_update, + "partial_update": can_update, + "retrieve": can_get, + } + + +class TemplateAccess(BaseAccess): + """Relation model to give access to a template for a user or a team with a role.""" + + template = models.ForeignKey( + Template, + on_delete=models.CASCADE, + related_name="accesses", + ) + + class Meta: + db_table = "impress_template_access" + ordering = ("-created_at",) + verbose_name = _("Template/user relation") + verbose_name_plural = _("Template/user relations") + constraints = [ + models.UniqueConstraint( + fields=["user", "template"], + condition=models.Q(user__isnull=False), # Exclude null users + name="unique_template_user", + violation_error_message=_("This user is already in this template."), + ), + models.UniqueConstraint( + fields=["team", "template"], + condition=models.Q(team__gt=""), # Exclude empty string teams + name="unique_template_team", + violation_error_message=_("This team is already in this template."), + ), + models.CheckConstraint( + check=models.Q(user__isnull=False, team="") + | models.Q(user__isnull=True, team__gt=""), + name="check_template_access_either_user_or_team", + violation_error_message=_("Either user or team must be set, not both."), + ), + ] + + def __str__(self): + return f"{self.user!s} is {self.role:s} in template {self.template!s}" + + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the template access. + """ + return self._get_abilities(self.template, user) + + +class Invitation(BaseModel): + """User invitation to a document.""" + + email = models.EmailField(_("email address"), null=False, blank=False) + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="invitations", + ) + role = models.CharField( + max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER + ) + issuer = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="invitations", + blank=True, + null=True, + ) + + class Meta: + db_table = "impress_invitation" + verbose_name = _("Document invitation") + verbose_name_plural = _("Document invitations") + constraints = [ + models.UniqueConstraint( + fields=["email", "document"], name="email_and_document_unique_together" + ) + ] + + def __str__(self): + return f"{self.email} invited to {self.document}" + + def clean(self): + """Validate fields.""" + super().clean() + + # Check if an identity already exists for the provided email + if ( + User.objects.filter(email=self.email).exists() + and not settings.OIDC_ALLOW_DUPLICATE_EMAILS + ): + raise ValidationError( + {"email": [_("This email is already associated to a registered user.")]} + ) + + @property + def is_expired(self): + """Calculate if invitation is still valid or has expired.""" + if not self.created_at: + return None + + validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION) + return timezone.now() > (self.created_at + validity_duration) + + def get_abilities(self, user): + """Compute and return abilities for a given user.""" + roles = [] + + if user.is_authenticated: + teams = user.teams + try: + roles = self.user_roles or [] + except AttributeError: + try: + roles = self.document.accesses.filter( + models.Q(user=user) | models.Q(team__in=teams), + ).values_list("role", flat=True) + except (self._meta.model.DoesNotExist, IndexError): + roles = [] + + is_admin_or_owner = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + + return { + "destroy": is_admin_or_owner, + "update": is_admin_or_owner, + "partial_update": is_admin_or_owner, + "retrieve": is_admin_or_owner, + } diff --git a/submissions/devoteam/docs/src/backend/core/services/__init__.py b/submissions/devoteam/docs/src/backend/core/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/services/ai_services.py b/submissions/devoteam/docs/src/backend/core/services/ai_services.py new file mode 100644 index 00000000..97ad583d --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/services/ai_services.py @@ -0,0 +1,93 @@ +"""AI services.""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from openai import OpenAI + +from core import enums + +AI_ACTIONS = { + "prompt": ( + "Answer the prompt in markdown format. " + "Preserve the language and markdown formatting. " + "Do not provide any other information. " + "Preserve the language." + ), + "correct": ( + "Correct grammar and spelling of the markdown text, " + "preserving language and markdown formatting. " + "Do not provide any other information. " + "Preserve the language." + ), + "rephrase": ( + "Rephrase the given markdown text, " + "preserving language and markdown formatting. " + "Do not provide any other information. " + "Preserve the language." + ), + "summarize": ( + "Summarize the markdown text, preserving language and markdown formatting. " + "Do not provide any other information. " + "Preserve the language." + ), + "beautify": ( + "Add formatting to the text to make it more readable. " + "Do not provide any other information. " + "Preserve the language." + ), + "emojify": ( + "Add emojis to the important parts of the text. " + "Do not provide any other information. " + "Preserve the language." + ), +} + +AI_TRANSLATE = ( + "Keep the same html structure and formatting. " + "Translate the content in the html to the specified language {language:s}. " + "Check the translation for accuracy and make any necessary corrections. " + "Do not provide any other information." +) + + +class AIService: + """Service class for AI-related operations.""" + + def __init__(self): + """Ensure that the AI configuration is set properly.""" + if ( + settings.AI_BASE_URL is None + or settings.AI_API_KEY is None + or settings.AI_MODEL is None + ): + raise ImproperlyConfigured("AI configuration not set") + self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY) + + def call_ai_api(self, system_content, text): + """Helper method to call the OpenAI API and process the response.""" + response = self.client.chat.completions.create( + model=settings.AI_MODEL, + messages=[ + {"role": "system", "content": system_content}, + {"role": "user", "content": text}, + ], + ) + + content = response.choices[0].message.content + + if not content: + raise RuntimeError("AI response does not contain an answer") + + return {"answer": content} + + def transform(self, text, action): + """Transform text based on specified action.""" + system_content = AI_ACTIONS[action] + return self.call_ai_api(system_content, text) + + def translate(self, text, language): + """Translate text to a specified language.""" + language_display = enums.ALL_LANGUAGES.get(language, language) + system_content = AI_TRANSLATE.format(language=language_display) + return self.call_ai_api(system_content, text) diff --git a/submissions/devoteam/docs/src/backend/core/services/collaboration_services.py b/submissions/devoteam/docs/src/backend/core/services/collaboration_services.py new file mode 100644 index 00000000..dac16fa6 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/services/collaboration_services.py @@ -0,0 +1,43 @@ +"""Collaboration services.""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +import requests + + +class CollaborationService: + """Service class for Collaboration related operations.""" + + def __init__(self): + """Ensure that the collaboration configuration is set properly.""" + if settings.COLLABORATION_API_URL is None: + raise ImproperlyConfigured("Collaboration configuration not set") + + def reset_connections(self, room, user_id=None): + """ + Reset connections of a room in the collaboration server. + Resetting a connection means that the user will be disconnected and will + have to reconnect to the collaboration server, with updated rights. + """ + endpoint = "reset-connections" + + # room is necessary as a parameter, it is easier to stick to the + # same pod thanks to a parameter + endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}" + + # Note: Collaboration microservice accepts only raw token, which is not recommended + headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET} + if user_id: + headers["X-User-Id"] = user_id + + try: + response = requests.post(endpoint_url, headers=headers, timeout=10) + except requests.RequestException as e: + raise requests.HTTPError("Failed to notify WebSocket server.") from e + + if response.status_code != 200: + raise requests.HTTPError( + f"Failed to notify WebSocket server. Status code: {response.status_code}, " + f"Response: {response.text}" + ) diff --git a/submissions/devoteam/docs/src/backend/core/services/converter_services.py b/submissions/devoteam/docs/src/backend/core/services/converter_services.py new file mode 100644 index 00000000..5213bac8 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/services/converter_services.py @@ -0,0 +1,78 @@ +"""Converter services.""" + +from django.conf import settings + +import requests + + +class ConversionError(Exception): + """Base exception for conversion-related errors.""" + + +class ValidationError(ConversionError): + """Raised when the input validation fails.""" + + +class ServiceUnavailableError(ConversionError): + """Raised when the conversion service is unavailable.""" + + +class InvalidResponseError(ConversionError): + """Raised when the conversion service returns an invalid response.""" + + +class MissingContentError(ConversionError): + """Raised when the response is missing required content.""" + + +class YdocConverter: + """Service class for conversion-related operations.""" + + @property + def auth_header(self): + """Build microservice authentication header.""" + # Note: Yprovider microservice accepts only raw token, which is not recommended + return settings.Y_PROVIDER_API_KEY + + def convert_markdown(self, text): + """Convert a Markdown text into our internal format using an external microservice.""" + + if not text: + raise ValidationError("Input text cannot be empty") + + try: + response = requests.post( + f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/", + json={ + "content": text, + }, + headers={ + "Authorization": self.auth_header, + "Content-Type": "application/json", + }, + timeout=settings.CONVERSION_API_TIMEOUT, + verify=settings.CONVERSION_API_SECURE, + ) + response.raise_for_status() + conversion_response = response.json() + + except requests.RequestException as err: + raise ServiceUnavailableError( + "Failed to connect to conversion service", + ) from err + + except ValueError as err: + raise InvalidResponseError( + "Could not parse conversion service response" + ) from err + + try: + document_content = conversion_response[ + settings.CONVERSION_API_CONTENT_FIELD + ] + except KeyError as err: + raise MissingContentError( + f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}" + ) from err + + return document_content diff --git a/submissions/devoteam/docs/src/backend/core/static/images/logo.png b/submissions/devoteam/docs/src/backend/core/static/images/logo.png new file mode 100644 index 00000000..bdd8de5d Binary files /dev/null and b/submissions/devoteam/docs/src/backend/core/static/images/logo.png differ diff --git a/submissions/devoteam/docs/src/backend/core/templates/core/generate_document.html b/submissions/devoteam/docs/src/backend/core/templates/core/generate_document.html new file mode 100644 index 00000000..ac9f2915 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/templates/core/generate_document.html @@ -0,0 +1,14 @@ + + + + Generate Document + + +

Generate Document

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ + diff --git a/submissions/devoteam/docs/src/backend/core/templates/mail/html/invitation.html b/submissions/devoteam/docs/src/backend/core/templates/mail/html/invitation.html new file mode 100644 index 00000000..8c2de039 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/templates/mail/html/invitation.html @@ -0,0 +1,256 @@ + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + +
{% load i18n static extra_tags %} {{ title }}
+
+ +
+ + + + + + +
+ + + +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+

{{title|capfirst}}

+
+
+
{{message|capfirst}} + {{document_title}} +
+
+ + + + + + +
+ {% trans "Open"%} +
+
+

+

+ +
+
{% blocktrans %} Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. {% endblocktrans %}
+
+
+

{% blocktrans %} Brought to you by {{brandname}} {% endblocktrans %}

+
+
+
+ +
+
+ +
+
+ +
+ + + diff --git a/submissions/devoteam/docs/src/backend/core/templates/mail/text/invitation.txt b/submissions/devoteam/docs/src/backend/core/templates/mail/text/invitation.txt new file mode 100644 index 00000000..6af54799 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/templates/mail/text/invitation.txt @@ -0,0 +1,16 @@ +{% load i18n static extra_tags %} {{ title }} + +{%trans 'Logo email' %} [{{logo_img}}] + + +{{title|capfirst}} + +{{message|capfirst}} {{document_title}} [{{link}}] + +{% trans "Open"%} [{{link}}] + + + +{% blocktrans %} Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. {% endblocktrans %} + +{% blocktrans %} Brought to you by {{brandname}} {% endblocktrans %} diff --git a/submissions/devoteam/docs/src/backend/core/templatetags/__init__.py b/submissions/devoteam/docs/src/backend/core/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/templatetags/extra_tags.py b/submissions/devoteam/docs/src/backend/core/templatetags/extra_tags.py new file mode 100644 index 00000000..109bd7b0 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/templatetags/extra_tags.py @@ -0,0 +1,58 @@ +"""Custom template tags for the core application of People.""" + +import base64 + +from django import template +from django.contrib.staticfiles import finders + +from PIL import ImageFile as PillowImageFile + +register = template.Library() + + +def image_to_base64(file_or_path, close=False): + """ + Return the src string of the base64 encoding of an image represented by its path + or file opened or not. + + Inspired by Django's "get_image_dimensions" + """ + pil_parser = PillowImageFile.Parser() + if hasattr(file_or_path, "read"): + file = file_or_path + if file.closed and hasattr(file, "open"): + file_or_path.open() + file_pos = file.tell() + file.seek(0) + else: + try: + # pylint: disable=consider-using-with + file = open(file_or_path, "rb") + except OSError: + return "" + close = True + + try: + image_data = file.read() + if not image_data: + return "" + pil_parser.feed(image_data) + if pil_parser.image: + mime_type = pil_parser.image.get_format_mimetype() + encoded_string = base64.b64encode(image_data) + return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}" + return "" + finally: + if close: + file.close() + else: + file.seek(file_pos) + + +@register.simple_tag +def base64_static(path): + """Return a static file into a base64.""" + full_path = finders.find(path) + if full_path: + return image_to_base64(full_path, True) + return "" diff --git a/submissions/devoteam/docs/src/backend/core/tests/__init__.py b/submissions/devoteam/docs/src/backend/core/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/tests/authentication/__init__.py b/submissions/devoteam/docs/src/backend/core/tests/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/tests/authentication/test_backends.py b/submissions/devoteam/docs/src/backend/core/tests/authentication/test_backends.py new file mode 100644 index 00000000..1636ad46 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/authentication/test_backends.py @@ -0,0 +1,511 @@ +"""Unit tests for the Authentication Backends.""" + +import random +import re + +from django.core.exceptions import SuspiciousOperation +from django.test.utils import override_settings + +import pytest +import responses +from cryptography.fernet import Fernet +from lasuite.oidc_login.backends import get_oidc_refresh_token + +from core import models +from core.authentication.backends import OIDCAuthenticationBackend +from core.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +def test_authentication_getter_existing_user_no_email( + django_assert_num_queries, monkeypatch +): + """ + If an existing user matches the user's info sub, the user should be returned. + """ + + klass = OIDCAuthenticationBackend() + db_user = UserFactory() + + def get_userinfo_mocked(*args): + return {"sub": db_user.sub} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + with django_assert_num_queries(1): + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user == db_user + + +def test_authentication_getter_existing_user_via_email( + django_assert_num_queries, monkeypatch +): + """ + If an existing user doesn't match the sub but matches the email, + the user should be returned. + """ + + klass = OIDCAuthenticationBackend() + db_user = UserFactory() + + def get_userinfo_mocked(*args): + return {"sub": "123", "email": db_user.email} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + with django_assert_num_queries(3): # user by sub, user by mail, update sub + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user == db_user + + +def test_authentication_getter_email_none(monkeypatch): + """ + If no user is found with the sub and no email is provided, a new user should be created. + """ + + klass = OIDCAuthenticationBackend() + db_user = UserFactory(email=None) + + def get_userinfo_mocked(*args): + user_info = {"sub": "123"} + if random.choice([True, False]): + user_info["email"] = None + return user_info + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + # Since the sub and email didn't match, it should create a new user + assert models.User.objects.count() == 2 + assert user != db_user + assert user.sub == "123" + + +def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate( + settings, monkeypatch +): + """ + When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False, + the system should not match users by email, even if the email matches. + """ + + klass = OIDCAuthenticationBackend() + db_user = UserFactory() + + # Set the setting to False + settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False + settings.OIDC_ALLOW_DUPLICATE_EMAILS = True + + def get_userinfo_mocked(*args): + return {"sub": "123", "email": db_user.email} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + # Since the sub doesn't match, it should create a new user + assert models.User.objects.count() == 2 + assert user != db_user + assert user.sub == "123" + + +def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate( + settings, monkeypatch +): + """ + When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False, + the system should not match users by email, even if the email matches. + """ + + klass = OIDCAuthenticationBackend() + db_user = UserFactory() + + # Set the setting to False + settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False + settings.OIDC_ALLOW_DUPLICATE_EMAILS = False + + def get_userinfo_mocked(*args): + return {"sub": "123", "email": db_user.email} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + with pytest.raises( + SuspiciousOperation, + match=( + "We couldn't find a user with this sub but the email is already associated " + "with a registered user." + ), + ): + klass.get_or_create_user(access_token="test-token", id_token=None, payload=None) + + # Since the sub doesn't match, it should not create a new user + assert models.User.objects.count() == 1 + + +def test_authentication_getter_existing_user_with_email( + django_assert_num_queries, monkeypatch +): + """ + When the user's info contains an email and targets an existing user, + """ + klass = OIDCAuthenticationBackend() + user = UserFactory(full_name="John Doe", short_name="John") + + def get_userinfo_mocked(*args): + return { + "sub": user.sub, + "email": user.email, + "first_name": "John", + "last_name": "Doe", + } + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + # Only 1 query because email and names have not changed + with django_assert_num_queries(1): + authenticated_user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user == authenticated_user + + +@pytest.mark.parametrize( + "first_name, last_name, email", + [ + ("Jack", "Doe", "john.doe@example.com"), + ("John", "Duy", "john.doe@example.com"), + ("John", "Doe", "jack.duy@example.com"), + ("Jack", "Duy", "jack.duy@example.com"), + ], +) +def test_authentication_getter_existing_user_change_fields_sub( + first_name, last_name, email, django_assert_num_queries, monkeypatch +): + """ + It should update the email or name fields on the user when they change + and the user was identified by its "sub". + """ + klass = OIDCAuthenticationBackend() + user = UserFactory( + full_name="John Doe", short_name="John", email="john.doe@example.com" + ) + + def get_userinfo_mocked(*args): + return { + "sub": user.sub, + "email": email, + "first_name": first_name, + "last_name": last_name, + } + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + # One and only one additional update query when a field has changed + with django_assert_num_queries(2): + authenticated_user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user == authenticated_user + user.refresh_from_db() + assert user.email == email + assert user.full_name == f"{first_name:s} {last_name:s}" + assert user.short_name == first_name + + +@pytest.mark.parametrize( + "first_name, last_name, email", + [ + ("Jack", "Doe", "john.doe@example.com"), + ("John", "Duy", "john.doe@example.com"), + ], +) +def test_authentication_getter_existing_user_change_fields_email( + first_name, last_name, email, django_assert_num_queries, monkeypatch +): + """ + It should update the name fields on the user when they change + and the user was identified by its "email" as fallback. + """ + klass = OIDCAuthenticationBackend() + user = UserFactory( + full_name="John Doe", short_name="John", email="john.doe@example.com" + ) + + def get_userinfo_mocked(*args): + return { + "sub": "123", + "email": user.email, + "first_name": first_name, + "last_name": last_name, + } + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + # One and only one additional update query when a field has changed + with django_assert_num_queries(3): + authenticated_user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user == authenticated_user + user.refresh_from_db() + assert user.email == email + assert user.full_name == f"{first_name:s} {last_name:s}" + assert user.short_name == first_name + + +def test_authentication_getter_new_user_no_email(monkeypatch): + """ + If no user matches the user's info sub, a user should be created. + User's info doesn't contain an email, created user's email should be empty. + """ + klass = OIDCAuthenticationBackend() + + def get_userinfo_mocked(*args): + return {"sub": "123"} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user.sub == "123" + assert user.email is None + assert user.full_name is None + assert user.short_name is None + assert user.has_usable_password() is False + assert models.User.objects.count() == 1 + + +def test_authentication_getter_new_user_with_email(monkeypatch): + """ + If no user matches the user's info sub, a user should be created. + User's email and name should be set on the identity. + The "email" field on the User model should not be set as it is reserved for staff users. + """ + klass = OIDCAuthenticationBackend() + + email = "impress@example.com" + + def get_userinfo_mocked(*args): + return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user.sub == "123" + assert user.email == email + assert user.full_name == "John Doe" + assert user.short_name == "John" + assert user.has_usable_password() is False + assert models.User.objects.count() == 1 + + +@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo") +@responses.activate +def test_authentication_get_userinfo_json_response(): + """Test get_userinfo method with a JSON response.""" + + responses.add( + responses.GET, + re.compile(r".*/userinfo"), + json={ + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + }, + status=200, + ) + + oidc_backend = OIDCAuthenticationBackend() + result = oidc_backend.get_userinfo("fake_access_token", None, None) + + assert result["first_name"] == "John" + assert result["last_name"] == "Doe" + assert result["email"] == "john.doe@example.com" + + +@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo") +@responses.activate +def test_authentication_get_userinfo_token_response(monkeypatch, settings): + """Test get_userinfo method with a token response.""" + settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call + responses.add( + responses.GET, + re.compile(r".*/userinfo"), + body="fake.jwt.token", + status=200, + content_type="application/jwt", + ) + + def mock_verify_token(self, token): # pylint: disable=unused-argument + return { + "first_name": "Jane", + "last_name": "Doe", + "email": "jane.doe@example.com", + } + + monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token) + + oidc_backend = OIDCAuthenticationBackend() + result = oidc_backend.get_userinfo("fake_access_token", None, None) + + assert result["first_name"] == "Jane" + assert result["last_name"] == "Doe" + assert result["email"] == "jane.doe@example.com" + + +@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo") +@responses.activate +def test_authentication_get_userinfo_invalid_response(settings): + """ + Test get_userinfo method with an invalid JWT response that + causes verify_token to raise an error. + """ + settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call + responses.add( + responses.GET, + re.compile(r".*/userinfo"), + body="fake.jwt.token", + status=200, + content_type="application/jwt", + ) + + oidc_backend = OIDCAuthenticationBackend() + + with pytest.raises( + SuspiciousOperation, + match="User info response was not valid JWT", + ): + oidc_backend.get_userinfo("fake_access_token", None, None) + + +def test_authentication_getter_existing_disabled_user_via_sub( + django_assert_num_queries, monkeypatch +): + """ + If an existing user matches the sub but is disabled, + an error should be raised and a user should not be created. + """ + + klass = OIDCAuthenticationBackend() + db_user = UserFactory(is_active=False) + + def get_userinfo_mocked(*args): + return { + "sub": db_user.sub, + "email": db_user.email, + "first_name": "John", + "last_name": "Doe", + } + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + with ( + django_assert_num_queries(1), + pytest.raises(SuspiciousOperation, match="User account is disabled"), + ): + klass.get_or_create_user(access_token="test-token", id_token=None, payload=None) + + assert models.User.objects.count() == 1 + + +def test_authentication_getter_existing_disabled_user_via_email( + django_assert_num_queries, monkeypatch +): + """ + If an existing user does not match the sub but matches the email and is disabled, + an error should be raised and a user should not be created. + """ + + klass = OIDCAuthenticationBackend() + db_user = UserFactory(is_active=False) + + def get_userinfo_mocked(*args): + return { + "sub": "random", + "email": db_user.email, + "first_name": "John", + "last_name": "Doe", + } + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + with ( + django_assert_num_queries(2), + pytest.raises(SuspiciousOperation, match="User account is disabled"), + ): + klass.get_or_create_user(access_token="test-token", id_token=None, payload=None) + + assert models.User.objects.count() == 1 + + +@responses.activate +def test_authentication_session_tokens( + django_assert_num_queries, monkeypatch, rf, settings +): + """ + Test that the session contains oidc_refresh_token and oidc_access_token after authentication. + """ + settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token" + settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo" + settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks" + settings.OIDC_STORE_ACCESS_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key() + + klass = OIDCAuthenticationBackend() + request = rf.get("/some-url", {"state": "test-state", "code": "test-code"}) + request.session = {} + + def verify_token_mocked(*args, **kwargs): + return {"sub": "123", "email": "test@example.com"} + + monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked) + + responses.add( + responses.POST, + re.compile(settings.OIDC_OP_TOKEN_ENDPOINT), + json={ + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + }, + status=200, + ) + + responses.add( + responses.GET, + re.compile(settings.OIDC_OP_USER_ENDPOINT), + json={"sub": "123", "email": "test@example.com"}, + status=200, + ) + + with django_assert_num_queries(6): + user = klass.authenticate( + request, + code="test-code", + nonce="test-nonce", + code_verifier="test-code-verifier", + ) + + assert user is not None + assert request.session["oidc_access_token"] == "test-access-token" + assert get_oidc_refresh_token(request.session) == "test-refresh-token" diff --git a/submissions/devoteam/docs/src/backend/core/tests/commands/test_update_files_content_type_metadata.py b/submissions/devoteam/docs/src/backend/core/tests/commands/test_update_files_content_type_metadata.py new file mode 100644 index 00000000..3ef78314 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/commands/test_update_files_content_type_metadata.py @@ -0,0 +1,50 @@ +""" +Unit test for `update_files_content_type_metadata` command. +""" + +import uuid + +from django.core.files.storage import default_storage +from django.core.management import call_command + +import pytest + +from core import factories + + +@pytest.mark.django_db +def test_update_files_content_type_metadata(): + """ + Test that the command `update_files_content_type_metadata` + fixes the ContentType of attachment in the storage. + """ + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + + # Create files with a wrong ContentType + keys = [] + for _ in range(10): + doc_id = uuid.uuid4() + factories.DocumentFactory(id=doc_id) + key = f"{doc_id}/attachments/testfile.png" + keys.append(key) + fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..." + s3_client.put_object( + Bucket=bucket_name, + Key=key, + Body=fake_png, + ContentType="text/plain", + Metadata={"owner": "None"}, + ) + + # Call the command that fixes the ContentType + call_command("update_files_content_type_metadata") + + for key in keys: + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + assert head_resp["ContentType"] == "image/png", ( + f"ContentType not fixed, got {head_resp['ContentType']!r}" + ) + + # Check that original metadata was preserved + assert head_resp["Metadata"].get("owner") == "None" diff --git a/submissions/devoteam/docs/src/backend/core/tests/conftest.py b/submissions/devoteam/docs/src/backend/core/tests/conftest.py new file mode 100644 index 00000000..00e830e1 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/conftest.py @@ -0,0 +1,26 @@ +"""Fixtures for tests in the impress core application""" + +from unittest import mock + +from django.core.cache import cache + +import pytest + +USER = "user" +TEAM = "team" +VIA = [USER, TEAM] + + +@pytest.fixture(autouse=True) +def clear_cache(): + """Fixture to clear the cache before each test.""" + cache.clear() + + +@pytest.fixture +def mock_user_teams(): + """Mock for the "teams" property on the User model.""" + with mock.patch( + "core.models.User.teams", new_callable=mock.PropertyMock + ) as mock_teams: + yield mock_teams diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_accesses.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_accesses.py new file mode 100644 index 00000000..bf5ef182 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_accesses.py @@ -0,0 +1,966 @@ +""" +Test document accesses API endpoints for users in impress's core app. +""" + +import random +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA +from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import + mock_reset_connections, +) + +pytestmark = pytest.mark.django_db + + +def test_api_document_accesses_list_anonymous(): + """Anonymous users should not be allowed to list document accesses.""" + document = factories.DocumentFactory() + factories.UserDocumentAccessFactory.create_batch(2, document=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/accesses/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_document_accesses_list_authenticated_unrelated(): + """ + Authenticated users should not be allowed to list document accesses for a document + to which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + factories.UserDocumentAccessFactory.create_batch(3, document=document) + + # Accesses for other documents to which the user is related should not be listed either + other_access = factories.UserDocumentAccessFactory(user=user) + factories.UserDocumentAccessFactory(document=other_access.document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +def test_api_document_accesses_list_unexisting_document(): + """ + Listing document accesses for an unexisting document should return an empty list. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/") + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize( + "role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES] +) +def test_api_document_accesses_list_authenticated_related_non_privileged( + via, role, mock_user_teams +): + """ + Authenticated users should be able to list document accesses for a document + to which they are directly related, whatever their role in the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + owner = factories.UserFactory() + accesses = [] + + document_access = factories.UserDocumentAccessFactory( + user=owner, role=models.RoleChoices.OWNER + ) + accesses.append(document_access) + document = document_access.document + if via == USER: + models.DocumentAccess.objects.create( + document=document, + user=user, + role=role, + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + models.DocumentAccess.objects.create( + document=document, + team="lasuite", + role=role, + ) + + access1 = factories.TeamDocumentAccessFactory(document=document) + access2 = factories.UserDocumentAccessFactory(document=document) + accesses.append(access1) + accesses.append(access2) + + # Accesses for other documents to which the user is related should not be listed either + other_access = factories.UserDocumentAccessFactory(user=user) + factories.UserDocumentAccessFactory(document=other_access.document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/", + ) + + # Return only owners + owners_accesses = [ + access for access in accesses if access.role in models.PRIVILEGED_ROLES + ] + assert response.status_code == 200 + content = response.json() + assert content["count"] == len(owners_accesses) + assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + [ + { + "id": str(access.id), + "user": { + "id": None, + "email": None, + "full_name": access.user.full_name, + "short_name": access.user.short_name, + } + if access.user + else None, + "team": access.team, + "role": access.role, + "abilities": access.get_abilities(user), + } + for access in owners_accesses + ], + key=lambda x: x["id"], + ) + + for access in content["results"]: + assert access["role"] in models.PRIVILEGED_ROLES + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES) +def test_api_document_accesses_list_authenticated_related_privileged_roles( + via, role, mock_user_teams +): + """ + Authenticated users should be able to list document accesses for a document + to which they are directly related, whatever their role in the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + owner = factories.UserFactory() + accesses = [] + + document_access = factories.UserDocumentAccessFactory( + user=owner, role=models.RoleChoices.OWNER + ) + accesses.append(document_access) + document = document_access.document + user_access = None + if via == USER: + user_access = models.DocumentAccess.objects.create( + document=document, + user=user, + role=role, + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + user_access = models.DocumentAccess.objects.create( + document=document, + team="lasuite", + role=role, + ) + + access1 = factories.TeamDocumentAccessFactory(document=document) + access2 = factories.UserDocumentAccessFactory(document=document) + accesses.append(access1) + accesses.append(access2) + + # Accesses for other documents to which the user is related should not be listed either + other_access = factories.UserDocumentAccessFactory(user=user) + factories.UserDocumentAccessFactory(document=other_access.document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/", + ) + + access2_user = serializers.UserSerializer(instance=access2.user).data + base_user = serializers.UserSerializer(instance=user).data + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 4 + assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + [ + { + "id": str(user_access.id), + "user": base_user if via == "user" else None, + "team": "lasuite" if via == "team" else "", + "role": user_access.role, + "abilities": user_access.get_abilities(user), + }, + { + "id": str(access1.id), + "user": None, + "team": access1.team, + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": access2_user, + "team": "", + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + { + "id": str(document_access.id), + "user": serializers.UserSerializer(instance=owner).data, + "team": "", + "role": models.RoleChoices.OWNER, + "abilities": document_access.get_abilities(user), + }, + ], + key=lambda x: x["id"], + ) + + +def test_api_document_accesses_retrieve_anonymous(): + """ + Anonymous users should not be allowed to retrieve a document access. + """ + access = factories.UserDocumentAccessFactory() + + response = APIClient().get( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_document_accesses_retrieve_authenticated_unrelated(): + """ + Authenticated users should not be allowed to retrieve a document access for + a document to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Accesses related to another document should be excluded even if the user is related to it + for access in [ + factories.UserDocumentAccessFactory(), + factories.UserDocumentAccessFactory(user=user), + ]: + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 404 + assert response.json() == { + "detail": "No DocumentAccess matches the given query." + } + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", models.RoleChoices) +def test_api_document_accesses_retrieve_authenticated_related( + via, role, mock_user_teams +): + """ + A user who is related to a document should be allowed to retrieve the + associated document user accesses. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + access = factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + if not role in models.PRIVILEGED_ROLES: + assert response.status_code == 403 + else: + access_user = serializers.UserSerializer(instance=access.user).data + + assert response.status_code == 200 + assert response.json() == { + "id": str(access.id), + "user": access_user, + "team": "", + "role": access.role, + "abilities": access.get_abilities(user), + } + + +def test_api_document_accesses_update_anonymous(): + """Anonymous users should not be allowed to update a document access.""" + access = factories.UserDocumentAccessFactory() + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 401 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_document_accesses_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a document access for a document to which + they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory() + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("role", ["reader", "editor"]) +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_authenticated_reader_or_editor( + via, role, mock_user_teams +): + """Readers or editors of a document should not be allowed to update its accesses.""" + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + access = factories.UserDocumentAccessFactory(document=document) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_administrator_except_owner( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + A user who is a direct administrator in a document should be allowed to update a user + access for this document, as long as they don't try to set the role to owner. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + access = factories.UserDocumentAccessFactory( + document=document, + role=random.choice(["administrator", "editor", "reader"]), + ) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(["administrator", "editor", "reader"]), + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + if new_data["role"] == old_values["role"]: + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + assert response.status_code == 403 + else: + with mock_reset_connections(document.id, str(access.user_id)): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_administrator_from_owner(via, mock_user_teams): + """ + A user who is an administrator in a document, should not be allowed to update + the user access of an "owner" for this document. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory() + access = factories.UserDocumentAccessFactory( + document=document, user=other_user, role="owner" + ) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={**old_values, field: value}, + format="json", + ) + + assert response.status_code == 403 + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_administrator_to_owner( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + A user who is an administrator in a document, should not be allowed to update + the user access of another user to grant document ownership. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory() + access = factories.UserDocumentAccessFactory( + document=document, + user=other_user, + role=random.choice(["administrator", "editor", "reader"]), + ) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": "owner", + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + # We are not allowed or not really updating the role + if field == "role" or new_data["role"] == old_values["role"]: + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + + assert response.status_code == 403 + else: + with mock_reset_connections(document.id, str(access.user_id)): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_owner( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + A user who is an owner in a document should be allowed to update + a user access for this document whatever the role. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + factories.UserFactory() + access = factories.UserDocumentAccessFactory( + document=document, + ) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + assert response.status_code == 403 + else: + with mock_reset_connections(document.id, str(access.user_id)): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_owner_self( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + A user who is owner of a document should be allowed to update + their own user access provided there are other owners in the document. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = None + if via == USER: + access = factories.UserDocumentAccessFactory( + document=document, user=user, role="owner" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + old_values = serializers.DocumentAccessSerializer(instance=access).data + new_role = random.choice(["administrator", "editor", "reader"]) + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 403 + access.refresh_from_db() + assert access.role == "owner" + + # Add another owner and it should now work + factories.UserDocumentAccessFactory(document=document, role="owner") + + user_id = str(access.user_id) if via == USER else None + with mock_reset_connections(document.id, user_id): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={ + **old_values, + "role": new_role, + "user_id": old_values.get("user", {}).get("id") + if old_values.get("user") is not None + else None, + }, + format="json", + ) + + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role + + +# Delete + + +def test_api_document_accesses_delete_anonymous(): + """Anonymous users should not be allowed to destroy a document access.""" + access = factories.UserDocumentAccessFactory() + + response = APIClient().delete( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert models.DocumentAccess.objects.count() == 1 + + +def test_api_document_accesses_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a document access for a + document to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory() + + response = client.delete( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.DocumentAccess.objects.count() == 2 + + +@pytest.mark.parametrize("role", ["reader", "editor"]) +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_teams): + """ + Authenticated users should not be allowed to delete a document access for a + document in which they are a simple reader or editor. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + access = factories.UserDocumentAccessFactory(document=document) + + assert models.DocumentAccess.objects.count() == 3 + assert models.DocumentAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.DocumentAccess.objects.count() == 3 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_administrators_except_owners( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + Users who are administrators in a document should be allowed to delete an access + from the document provided it is not ownership. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + access = factories.UserDocumentAccessFactory( + document=document, role=random.choice(["reader", "editor", "administrator"]) + ) + + assert models.DocumentAccess.objects.count() == 2 + assert models.DocumentAccess.objects.filter(user=access.user).exists() + + with mock_reset_connections(document.id, str(access.user_id)): + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_teams): + """ + Users who are administrators in a document should not be allowed to delete an ownership + access from the document. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + access = factories.UserDocumentAccessFactory(document=document, role="owner") + + assert models.DocumentAccess.objects.count() == 3 + assert models.DocumentAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.DocumentAccess.objects.count() == 3 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_owners( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + Users should be able to delete the document access of another user + for a document of which they are owner. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + access = factories.UserDocumentAccessFactory(document=document) + + assert models.DocumentAccess.objects.count() == 2 + assert models.DocumentAccess.objects.filter(user=access.user).exists() + + with mock_reset_connections(document.id, str(access.user_id)): + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams): + """ + It should not be possible to delete the last owner access from a document + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = None + if via == USER: + access = factories.UserDocumentAccessFactory( + document=document, user=user, role="owner" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + assert models.DocumentAccess.objects.count() == 2 + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.DocumentAccess.objects.count() == 2 diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_accesses_create.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_accesses_create.py new file mode 100644 index 00000000..e356973a --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -0,0 +1,310 @@ +""" +Test document accesses API endpoints for users in impress's core app. +""" + +import random + +from django.core import mail + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +# Create + + +def test_api_document_accesses_create_anonymous(): + """Anonymous users should not be allowed to create document accesses.""" + document = factories.DocumentFactory() + + other_user = factories.UserFactory() + response = APIClient().post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user_id": str(other_user.id), + "document": str(document.id), + "role": random.choice(models.RoleChoices.values), + }, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + assert models.DocumentAccess.objects.exists() is False + + +def test_api_document_accesses_create_authenticated_unrelated(): + """ + Authenticated users should not be allowed to create document accesses for a document to + which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + document = factories.DocumentFactory() + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user_id": str(other_user.id), + }, + format="json", + ) + + assert response.status_code == 403 + assert not models.DocumentAccess.objects.filter(user=other_user).exists() + + +@pytest.mark.parametrize("role", ["reader", "editor"]) +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_authenticated_reader_or_editor( + via, role, mock_user_teams +): + """Readers or editors of a document should not be allowed to create document accesses.""" + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + other_user = factories.UserFactory() + + for new_role in [role[0] for role in models.RoleChoices.choices]: + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user_id": str(other_user.id), + "role": new_role, + }, + format="json", + ) + + assert response.status_code == 403 + + assert not models.DocumentAccess.objects.filter(user=other_user).exists() + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams): + """ + Administrators of a document should be able to create document accesses + except for the "owner" role. + An email should be sent to the accesses to notify them of the adding. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory(language="en-us") + + # It should not be allowed to create an owner access + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user_id": str(other_user.id), + "role": "owner", + }, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Only owners of a resource can assign other users as owners." + } + + # It should be allowed to create a lower access + role = random.choice( + [role[0] for role in models.RoleChoices.choices if role[0] != "owner"] + ) + + assert len(mail.outbox) == 0 + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user_id": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.DocumentAccess.objects.filter(user=other_user).count() == 1 + new_document_access = models.DocumentAccess.objects.filter(user=other_user).get() + other_user = serializers.UserSerializer(instance=other_user).data + assert response.json() == { + "abilities": new_document_access.get_abilities(user), + "id": str(new_document_access.id), + "team": "", + "role": role, + "user": other_user, + } + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == [other_user["email"]] + email_content = " ".join(email.body.split()) + assert f"{user.full_name} shared a document with you!" in email_content + assert ( + f"{user.full_name} ({user.email}) invited you with the role "{role}" " + f"on the following document: {document.title}" + ) in email_content + assert "docs/" + str(document.id) + "/" in email_content + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams): + """ + Owners of a document should be able to create document accesses whatever the role. + An email should be sent to the accesses to notify them of the adding. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + other_user = factories.UserFactory(language="en-us") + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + assert len(mail.outbox) == 0 + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user_id": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.DocumentAccess.objects.filter(user=other_user).count() == 1 + new_document_access = models.DocumentAccess.objects.filter(user=other_user).get() + other_user = serializers.UserSerializer(instance=other_user).data + assert response.json() == { + "id": str(new_document_access.id), + "user": other_user, + "team": "", + "role": role, + "abilities": new_document_access.get_abilities(user), + } + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == [other_user["email"]] + email_content = " ".join(email.body.split()) + assert f"{user.full_name} shared a document with you!" in email_content + assert ( + f"{user.full_name} ({user.email}) invited you with the role "{role}" " + f"on the following document: {document.title}" + ) in email_content + assert "docs/" + str(document.id) + "/" in email_content + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams): + """ + The email sent to the accesses to notify them of the adding, should be in their language. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + assert len(mail.outbox) == 0 + + other_users = ( + factories.UserFactory(language="en-us"), + factories.UserFactory(language="fr-fr"), + ) + + for index, other_user in enumerate(other_users): + expected_language = other_user.language + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user_id": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.DocumentAccess.objects.filter(user=other_user).count() == 1 + new_document_access = models.DocumentAccess.objects.filter( + user=other_user + ).get() + other_user_data = serializers.UserSerializer(instance=other_user).data + assert response.json() == { + "id": str(new_document_access.id), + "user": other_user_data, + "team": "", + "role": role, + "abilities": new_document_access.get_abilities(user), + } + assert len(mail.outbox) == index + 1 + email = mail.outbox[index] + assert email.to == [other_user_data["email"]] + email_content = " ".join(email.body.split()) + email_subject = " ".join(email.subject.split()) + if expected_language == "en-us": + assert ( + f"{user.full_name} shared a document with you: {document.title}".lower() + in email_subject.lower() + ) + elif expected_language == "fr-fr": + assert ( + f"{user.full_name} a partagé un document avec vous : {document.title}".lower() + in email_subject.lower() + ) + assert "docs/" + str(document.id) + "/" in email_content.lower() diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_invitations.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_invitations.py new file mode 100644 index 00000000..16090b7d --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_invitations.py @@ -0,0 +1,826 @@ +""" +Unit tests for the Invitation model +""" + +import random +from datetime import timedelta +from unittest import mock + +from django.core import mail +from django.test import override_settings +from django.utils import timezone + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +# List + + +def test_api_document_invitations_list_anonymous_user(): + """Anonymous users should not be able to list invitations.""" + invitation = factories.InvitationFactory() + response = APIClient().get( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/" + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", ["owner", "administrator"]) +def test_api_document_invitations_list_authenticated_privileged( + role, via, mock_user_teams, django_assert_num_queries +): + """ + Authenticated users should be able to list invitations for documents to which they are + related with administrator or owner privilege, including invitations issued by other users. + """ + user = factories.UserFactory() + other_user = factories.UserFactory() + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + invitation = factories.InvitationFactory(document=document, issuer=user) + other_invitations = factories.InvitationFactory.create_batch( + 2, document=document, issuer=other_user + ) + + # invitations from other documents should not be listed + other_document = factories.DocumentFactory() + factories.InvitationFactory.create_batch(2, document=other_document) + + client = APIClient() + client.force_login(user) + with django_assert_num_queries(3): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/invitations/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted( + [ + { + "id": str(i.id), + "created_at": i.created_at.isoformat().replace("+00:00", "Z"), + "email": str(i.email), + "document": str(document.id), + "role": i.role, + "issuer": str(i.issuer.id), + "is_expired": False, + "abilities": { + "destroy": role in ["administrator", "owner"], + "update": role in ["administrator", "owner"], + "partial_update": role in ["administrator", "owner"], + "retrieve": True, + }, + } + for i in [invitation, *other_invitations] + ], + key=lambda x: x["created_at"], + ) + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", ["reader", "editor"]) +def test_api_document_invitations_list_authenticated_unprivileged( + role, via, mock_user_teams, django_assert_num_queries +): + """ + Authenticated users should not be able to list invitations for documents to which they are + related with reader or editor role, including invitations issued by other users. + """ + user = factories.UserFactory() + other_user = factories.UserFactory() + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + factories.InvitationFactory(document=document, issuer=user) + factories.InvitationFactory.create_batch(2, document=document, issuer=other_user) + + # invitations from other documents should not be listed + other_document = factories.DocumentFactory() + factories.InvitationFactory.create_batch(2, document=other_document) + + client = APIClient() + client.force_login(user) + with django_assert_num_queries(2): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/invitations/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 0 + + +def test_api_document_invitations_list_expired_invitations_still_listed(): + """ + Expired invitations are still listed. + """ + user = factories.UserFactory() + other_user = factories.UserFactory() + + document = factories.DocumentFactory( + users=[(user, "administrator"), (other_user, "owner")] + ) + + expired_invitation = factories.InvitationFactory( + document=document, + role="reader", + issuer=user, + ) + + client = APIClient() + client.force_login(user) + + # mock timezone.now to accelerate validation expiration + too_late = timezone.now() + timedelta(seconds=604800) # 7 days + with mock.patch("django.utils.timezone.now", return_value=too_late): + assert expired_invitation.is_expired is True + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/invitations/", + ) + + assert response.status_code == 200 + assert response.json()["count"] == 1 + assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted( + [ + { + "id": str(expired_invitation.id), + "created_at": expired_invitation.created_at.isoformat().replace( + "+00:00", "Z" + ), + "email": str(expired_invitation.email), + "document": str(document.id), + "role": expired_invitation.role, + "issuer": str(expired_invitation.issuer.id), + "is_expired": True, + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + }, + }, + ], + key=lambda x: x["created_at"], + ) + + +# Retrieve + + +def test_api_document_invitations_retrieve_anonymous_user(): + """ + Anonymous users should not be able to retrieve invitations. + """ + + invitation = factories.InvitationFactory() + response = APIClient().get( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/", + ) + + assert response.status_code == 401 + + +def test_api_document_invitations_retrieve_unrelated_user(): + """ + Authenticated unrelated users should not be able to retrieve invitations. + """ + user = factories.UserFactory() + invitation = factories.InvitationFactory() + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/", + ) + + assert response.status_code == 403 + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", ["administrator", "owner"]) +def test_api_document_invitations_retrieve_document_privileged( + role, via, mock_user_teams +): + """ + Authenticated users related to the document should be able to retrieve invitations + provided they are administrators or owners of the document. + """ + user = factories.UserFactory() + invitation = factories.InvitationFactory() + + if via == USER: + factories.UserDocumentAccessFactory( + document=invitation.document, user=user, role=role + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=invitation.document, team="lasuite", role=role + ) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(invitation.id), + "created_at": invitation.created_at.isoformat().replace("+00:00", "Z"), + "email": invitation.email, + "document": str(invitation.document.id), + "role": str(invitation.role), + "issuer": str(invitation.issuer.id), + "is_expired": False, + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + }, + } + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", ["reader", "editor"]) +def test_api_document_invitations_retrieve_document_unprivileged( + role, via, mock_user_teams +): + """ + Authenticated users related to the document should not be able to retrieve invitations + if they are simply reader or editor of the document. + """ + user = factories.UserFactory() + invitation = factories.InvitationFactory() + + if via == USER: + factories.UserDocumentAccessFactory( + document=invitation.document, user=user, role=role + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=invitation.document, team="lasuite", role=role + ) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/", + ) + + assert response.status_code == 403 + assert response.content + + +# Create + + +def test_api_document_invitations_create_anonymous(): + """Anonymous users should not be able to create invitations.""" + document = factories.DocumentFactory() + invitation_values = { + "email": "guest@example.com", + "role": random.choice(models.RoleChoices.values), + } + + response = APIClient().post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_document_invitations_create_authenticated_outsider(): + """Users outside of document should not be permitted to invite to document.""" + user = factories.UserFactory() + document = factories.DocumentFactory() + invitation_values = { + "email": "guest@example.com", + "role": random.choice(models.RoleChoices.values), + } + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + ) + + assert response.status_code == 403 + + +@override_settings(EMAIL_BRAND_NAME="My brand name", EMAIL_LOGO_IMG="my-img.jpg") +@pytest.mark.parametrize( + "inviting,invited,response_code", + ( + ["reader", "reader", 403], + ["reader", "editor", 403], + ["reader", "administrator", 403], + ["reader", "owner", 403], + ["editor", "reader", 403], + ["editor", "editor", 403], + ["editor", "administrator", 403], + ["editor", "owner", 403], + ["administrator", "reader", 201], + ["administrator", "editor", 201], + ["administrator", "administrator", 201], + ["administrator", "owner", 400], + ["owner", "reader", 201], + ["owner", "editor", 201], + ["owner", "administrator", 201], + ["owner", "owner", 201], + ), +) +@pytest.mark.parametrize("via", VIA) +def test_api_document_invitations_create_privileged_members( + via, inviting, invited, response_code, mock_user_teams +): + """ + Only owners and administrators should be able to invite new users. + Only owners can invite owners. + """ + user = factories.UserFactory(language="en-us") + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=inviting) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=inviting + ) + + invitation_values = { + "email": "guest@example.com", + "role": invited, + } + + assert len(mail.outbox) == 0 + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + ) + + assert response.status_code == response_code + + if response_code == 201: + assert models.Invitation.objects.count() == 1 + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["guest@example.com"] + email_content = " ".join(email.body.split()) + assert f"{user.full_name} shared a document with you!" in email_content + assert ( + f"{user.full_name} ({user.email}) invited you with the role "{invited}" " + f"on the following document: {document.title}" + ) in email_content + assert "My brand name" in email_content + assert "my-img.jpg" in email_content + else: + assert models.Invitation.objects.exists() is False + + if response_code == 400: + assert response.json() == { + "role": [ + "Only owners of a document can invite other users as owners.", + ], + } + + +def test_api_document_invitations_create_email_from_senders_language(): + """ + When inviting on a document a user who does not exist yet in our database, + the invitation email should be sent in the language of the sending user. + """ + user = factories.UserFactory(language="fr-fr") + document = factories.DocumentFactory() + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + + invitation_values = { + "email": "guest@example.com", + "role": "reader", + } + + assert len(mail.outbox) == 0 + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + ) + + assert response.status_code == 201 + assert response.json()["email"] == "guest@example.com" + assert models.Invitation.objects.count() == 1 + assert len(mail.outbox) == 1 + + email = mail.outbox[0] + + assert email.to == ["guest@example.com"] + + email_content = " ".join(email.body.split()) + assert f"{user.full_name} a partagé un document avec vous!" in email_content + assert ( + "Docs, votre nouvel outil incontournable pour organiser, partager et collaborer " + "sur vos documents en équipe." in email_content + ) + + +def test_api_document_invitations_create_email_full_name_empty(): + """ + If the full name of the user is empty, it will display the email address. + """ + user = factories.UserFactory(full_name="", language="en-us") + document = factories.DocumentFactory() + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + + invitation_values = { + "email": "guest@example.com", + "role": "reader", + } + + assert len(mail.outbox) == 0 + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + headers={"Content-Language": "not-supported"}, + ) + + assert response.status_code == 201 + assert response.json()["email"] == "guest@example.com" + assert models.Invitation.objects.count() == 1 + assert len(mail.outbox) == 1 + + email = mail.outbox[0] + + assert email.to == ["guest@example.com"] + + email_content = " ".join(email.body.split()) + assert f"{user.email} shared a document with you!" in email_content + assert ( + f"{user.email.capitalize()} invited you with the role "reader" on the " + f"following document: {document.title}" in email_content + ) + + +def test_api_document_invitations_create_issuer_and_document_override(): + """It should not be possible to set the "document" and "issuer" fields.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "owner")]) + other_document = factories.DocumentFactory(users=[(user, "owner")]) + invitation_values = { + "document": str(other_document.id), + "issuer": str(factories.UserFactory().id), + "email": "guest@example.com", + "role": random.choice(models.RoleChoices.values), + } + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + ) + + assert response.status_code == 201 + # document and issuer automatically set + assert response.json()["document"] == str(document.id) + assert response.json()["issuer"] == str(user.id) + + +def test_api_document_invitations_create_cannot_duplicate_invitation(): + """An email should not be invited multiple times to the same document.""" + existing_invitation = factories.InvitationFactory() + document = existing_invitation.document + + # Grant privileged role on the Document to the user + user = factories.UserFactory() + models.DocumentAccess.objects.create( + document=document, user=user, role="administrator" + ) + + # Create a new invitation to the same document with the exact same email address + invitation_values = { + "email": existing_invitation.email, + "role": random.choice(["administrator", "editor", "reader"]), + } + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "__all__": [ + "Document invitation with this Email address and Document already exists." + ], + } + + +def test_api_document_invitations_create_cannot_invite_existing_users(): + """ + It should not be possible to invite already existing users. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "owner")]) + existing_user = factories.UserFactory() + + # Build an invitation to the email of an existing identity in the db + invitation_values = { + "email": existing_user.email, + "role": random.choice(models.RoleChoices.values), + } + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "email": ["This email is already associated to a registered user."] + } + + +# Update + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", ["administrator", "owner"]) +def test_api_document_invitations_update_authenticated_privileged_any_field_except_role( + role, via, mock_user_teams +): + """ + Authenticated user can update invitations if they are administrator or owner of the document. + """ + user = factories.UserFactory() + invitation = factories.InvitationFactory() + + if via == USER: + factories.UserDocumentAccessFactory( + document=invitation.document, user=user, role=role + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=invitation.document, team="lasuite", role=role + ) + + old_invitation_values = serializers.InvitationSerializer(instance=invitation).data + new_invitation_values = serializers.InvitationSerializer( + instance=factories.InvitationFactory() + ).data + # The update of a role is tested in the next test + del new_invitation_values["role"] + + client = APIClient() + client.force_login(user) + + url = ( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/" + ) + response = client.put(url, new_invitation_values, format="json") + + assert response.status_code == 200 + + invitation.refresh_from_db() + invitation_values = serializers.InvitationSerializer(instance=invitation).data + + for key, value in invitation_values.items(): + if key == "email": + assert value == new_invitation_values[key] + elif key == "updated_at": + assert value > old_invitation_values[key] + else: + assert value == old_invitation_values[key] + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role_set", models.RoleChoices.values) +@pytest.mark.parametrize("role", ["administrator", "owner"]) +def test_api_document_invitations_update_authenticated_privileged_role( + role, role_set, via, mock_user_teams +): + """ + Authenticated user can update invitations if they are administrator or owner of the document, + but only owners can set the invitation role to the "owner" role. + """ + user = factories.UserFactory() + invitation = factories.InvitationFactory() + old_role = invitation.role + + if via == USER: + factories.UserDocumentAccessFactory( + document=invitation.document, user=user, role=role + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=invitation.document, team="lasuite", role=role + ) + + new_invitation_values = serializers.InvitationSerializer(instance=invitation).data + new_invitation_values["role"] = role_set + + client = APIClient() + client.force_login(user) + + url = ( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/" + ) + response = client.put(url, new_invitation_values, format="json") + + invitation.refresh_from_db() + + if role_set == "owner" and role != "owner": + assert response.status_code == 400 + assert invitation.role == old_role + assert response.json() == { + "role": [ + "Only owners of a document can invite other users as owners.", + ], + } + else: + assert response.status_code == 200 + assert invitation.role == role_set + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", ["reader", "editor"]) +def test_api_document_invitations_update_authenticated_unprivileged( + role, via, mock_user_teams +): + """ + Authenticated user should not be allowed to update invitations if they are + simple reader or editor of the document. + """ + user = factories.UserFactory() + invitation = factories.InvitationFactory() + + if via == USER: + factories.UserDocumentAccessFactory( + document=invitation.document, user=user, role=role + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=invitation.document, team="lasuite", role=role + ) + + old_invitation_values = serializers.InvitationSerializer(instance=invitation).data + new_invitation_values = serializers.InvitationSerializer( + instance=factories.InvitationFactory() + ).data + + client = APIClient() + client.force_login(user) + + url = ( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/" + ) + response = client.put(url, new_invitation_values, format="json") + + assert response.status_code == 403 + + invitation.refresh_from_db() + invitation_values = serializers.InvitationSerializer(instance=invitation).data + + for key, value in invitation_values.items(): + assert value == old_invitation_values[key] + + +# Delete + + +def test_api_document_invitations_delete_anonymous(): + """Anonymous user should not be able to delete invitations.""" + invitation = factories.InvitationFactory() + + response = APIClient().delete( + f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_document_invitations_delete_authenticated_outsider(): + """Members unrelated to a document should not be allowed to cancel invitations.""" + user = factories.UserFactory(with_owned_document=True) + + document = factories.DocumentFactory() + invitation = factories.InvitationFactory(document=document) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", ["owner", "administrator"]) +def test_api_document_invitations_delete_privileged_members(role, via, mock_user_teams): + """Privileged member should be able to cancel invitation.""" + user = factories.UserFactory() + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + invitation = factories.InvitationFactory(document=document) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/", + ) + assert response.status_code == 204 + + +@pytest.mark.parametrize("role", ["reader", "editor"]) +@pytest.mark.parametrize("via", VIA) +def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user_teams): + """Readers or editors should not be able to cancel invitation.""" + user = factories.UserFactory(with_owned_document=True) + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + invitation = factories.InvitationFactory(document=document) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/", + ) + assert response.status_code == 403 + assert ( + response.json()["detail"] + == "You do not have permission to perform this action." + ) diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_versions.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_versions.py new file mode 100644 index 00000000..83b8c7f5 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_document_versions.py @@ -0,0 +1,733 @@ +""" +Test document versions API endpoints for users in impress's core app. +""" + +import random +import time + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +@pytest.mark.parametrize("role", models.LinkRoleChoices.values) +def test_api_document_versions_list_anonymous(role, reach): + """ + Anonymous users should not be allowed to list document versions for a document + whatever the reach and role. + """ + document = factories.DocumentFactory(link_role=role, link_reach=reach) + + # Accesses and traces for other users should not interfere + factories.UserDocumentAccessFactory(document=document) + models.LinkTrace.objects.create(document=document, user=factories.UserFactory()) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/") + + assert response.status_code == 403 + assert response.json() == {"detail": "Authentication required."} + + +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_document_versions_list_authenticated_unrelated(reach): + """ + Authenticated users should not be allowed to list document versions for a document + to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + factories.UserDocumentAccessFactory.create_batch(3, document=document) + + # The versions of another document to which the user is related should not be listed either + factories.UserDocumentAccessFactory(user=user) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_list_authenticated_related_success(via, mock_user_teams): + """ + Authenticated users should be able to list document versions for a document + to which they are directly related, whatever their role in the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + models.DocumentAccess.objects.create( + document=document, + user=user, + role=random.choice(models.RoleChoices.values), + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + models.DocumentAccess.objects.create( + document=document, + team="lasuite", + role=random.choice(models.RoleChoices.values), + ) + + # Other versions of documents to which the user has access should not be listed + factories.UserDocumentAccessFactory(user=user) + + # A version created before the user got access should be hidden + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/", + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 0 + + # Add a new version to the document + for i in range(3): + document.content = f"new content {i:d}" + document.save() + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/", + ) + + assert response.status_code == 200 + content = response.json() + # The current version is not listed + assert content["count"] == 2 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_list_authenticated_related_pagination( + via, mock_user_teams +): + """ + The list of versions should be paginated and exclude versions that were created prior to the + user gaining access to the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + for i in range(3): + document.content = f"before {i:d}" + document.save() + + if via == USER: + models.DocumentAccess.objects.create( + document=document, + user=user, + role=random.choice(models.RoleChoices.values), + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + models.DocumentAccess.objects.create( + document=document, + team="lasuite", + role=random.choice(models.RoleChoices.values), + ) + + for i in range(4): + document.content = f"after {i:d}" + document.save() + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/", + ) + + content = response.json() + assert content["is_truncated"] is False + # The current version is not listed + assert content["count"] == 3 + assert content["next_version_id_marker"] == "" + all_version_ids = [version["version_id"] for version in content["versions"]] + + # - set page size + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2", + ) + + content = response.json() + assert content["count"] == 2 + assert content["is_truncated"] is True + marker = content["next_version_id_marker"] + assert marker == all_version_ids[1] + assert [ + version["version_id"] for version in content["versions"] + ] == all_version_ids[:2] + + # - get page 2 + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}", + ) + + content = response.json() + assert content["count"] == 1 + assert content["is_truncated"] is False + assert content["next_version_id_marker"] == "" + assert content["versions"][0]["version_id"] == all_version_ids[2] + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_list_authenticated_related_pagination_parent( + via, mock_user_teams +): + """ + When a user gains access to a document's versions via an ancestor, the date of access + to the parent should be used to filter versions that were created prior to the + user gaining access to the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory() + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + for i in range(3): + document.content = f"before {i:d}" + document.save() + + if via == USER: + models.DocumentAccess.objects.create( + document=grand_parent, + user=user, + role=random.choice(models.RoleChoices.values), + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + models.DocumentAccess.objects.create( + document=grand_parent, + team="lasuite", + role=random.choice(models.RoleChoices.values), + ) + + for i in range(4): + document.content = f"after {i:d}" + document.save() + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/", + ) + + content = response.json() + + assert response.status_code == 200 + assert content["is_truncated"] is False + # The current version is not listed + assert content["count"] == 3 + assert content["next_version_id_marker"] == "" + all_version_ids = [version["version_id"] for version in content["versions"]] + + # - set page size + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2", + ) + + content = response.json() + assert content["count"] == 2 + assert content["is_truncated"] is True + marker = content["next_version_id_marker"] + assert marker == all_version_ids[1] + assert [ + version["version_id"] for version in content["versions"] + ] == all_version_ids[:2] + + # - get page 2 + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}", + ) + + content = response.json() + assert content["count"] == 1 + assert content["is_truncated"] is False + assert content["next_version_id_marker"] == "" + assert content["versions"][0]["version_id"] == all_version_ids[2] + + +def test_api_document_versions_list_exceeds_max_page_size(): + """Page size should not exceed the limit set on the serializer""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[user]) + document.content = "version 2" + document.save() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/versions/?page_size=51") + + assert response.status_code == 400 + assert response.json() == { + "page_size": ["Ensure this value is less than or equal to 50."] + } + + +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_document_versions_retrieve_anonymous(reach): + """ + Anonymous users should not be allowed to find specific versions for a document with + restricted or authenticated link reach. + """ + document = factories.DocumentFactory(link_reach=reach) + document.content = "new content" + document.save() + + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/" + response = APIClient().get(url) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_document_versions_retrieve_authenticated_unrelated(reach): + """ + Authenticated users should not be allowed to retrieve specific versions for a + document to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + document.content = "new content" + document.save() + + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_retrieve_authenticated_related(via, mock_user_teams): + """ + A user who is related to a document should be allowed to retrieve the + associated document versions. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + document.content = "new content" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 1 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory(document=document, team="lasuite") + + time.sleep(1) # minio stores datetimes with the precision of a second + + # Versions created before the document was shared should not be seen by the user + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + + assert response.status_code == 404 + + # Create a new version should not make it available to the user because + # only the current version is available to the user but it is excluded + # from the list + document.content = "new content 1" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 2 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + + assert response.status_code == 404 + + # Adding one more version should make the previous version available to the user + document.content = "new content 2" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 3 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + + assert response.status_code == 200 + assert response.json()["content"] == "new content 1" + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_retrieve_authenticated_related_parent( + via, mock_user_teams +): + """ + A user who gains access to a document's versions via one of its ancestors, should be able to + retrieve the document versions. The date of access to the parent should be used to filter + versions that were created prior to the user gaining access to the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory() + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + document.content = "new content" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 1 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + if via == USER: + factories.UserDocumentAccessFactory(document=grand_parent, user=user) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory(document=grand_parent, team="lasuite") + + time.sleep(1) # minio stores datetimes with the precision of a second + + # Versions created before the document was shared should not be seen by the user + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + + assert response.status_code == 404 + + # Create a new version should not make it available to the user because + # only the current version is available to the user but it is excluded + # from the list + document.content = "new content 1" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 2 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + + assert response.status_code == 404 + + # Adding one more version should make the previous version available to the user + document.content = "new content 2" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 3 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + + assert response.status_code == 200 + assert response.json()["content"] == "new content 1" + + +def test_api_document_versions_create_anonymous(): + """Anonymous users should not be allowed to create document versions.""" + document = factories.DocumentFactory() + + response = APIClient().post( + f"/api/v1.0/documents/{document.id!s}/versions/", + {"foo": "bar"}, + format="json", + ) + + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "POST" not allowed.'} + + +def test_api_document_versions_create_authenticated_unrelated(): + """ + Authenticated users should not be allowed to create document versions for a document to + which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/versions/", + {"foo": "bar"}, + format="json", + ) + + assert response.status_code == 405 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_create_authenticated_related(via, mock_user_teams): + """ + Authenticated users related to a document should not be allowed to create document versions + whatever their role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory(document=document, team="lasuite") + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/versions/", + {"foo": "bar"}, + format="json", + ) + + assert response.status_code == 405 + + +def test_api_document_versions_update_anonymous(): + """Anonymous users should not be allowed to update a document version.""" + access = factories.UserDocumentAccessFactory() + document = access.document + document.content = "new content" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 1 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = APIClient().put( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + {"foo": "bar"}, + format="json", + ) + assert response.status_code == 405 + + +def test_api_document_versions_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a document version for a document to which + they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory() + document = access.document + document.content = "new content" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 1 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = client.put( + f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/", + {"foo": "bar"}, + format="json", + ) + assert response.status_code == 405 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_update_authenticated_related(via, mock_user_teams): + """ + Authenticated users with access to a document should not be able to update its versions + whatever their role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory(document=document, team="lasuite") + + time.sleep(1) # minio stores datetimes with the precision of a second + + document.content = "new content" + document.save() + + assert len(document.get_versions_slice()["versions"]) == 1 + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id!s}/", + {"foo": "bar"}, + format="json", + ) + assert response.status_code == 405 + + +# Delete + + +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_document_versions_delete_anonymous(reach): + """Anonymous users should not be allowed to destroy a document version.""" + access = factories.UserDocumentAccessFactory(document__link_reach=reach) + + response = APIClient().delete( + f"/api/v1.0/documents/{access.document_id!s}/versions/{access.id!s}/", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_document_versions_delete_authenticated(reach): + """ + Authenticated users should not be allowed to delete a document version for a + public document to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + document.content = "new content" + document.save() + + version_id = document.get_versions_slice()["versions"][0]["version_id"] + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", ["reader", "editor"]) +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_teams): + """ + Authenticated users should not be allowed to delete a document version for a + document in which they are a simple reader or editor. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + # Create a new version should make it available to the user + time.sleep(1) # minio stores datetimes with the precision of a second + document.content = "new content" + document.save() + + versions = document.get_versions_slice()["versions"] + assert len(versions) == 1 + + version_id = versions[0]["version_id"] + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + assert response.status_code == 403 + + versions = document.get_versions_slice()["versions"] + assert len(versions) == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_versions_delete_administrator_or_owner(via, mock_user_teams): + """ + Users who are administrator or owner of a document should be allowed to delete a version. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + role = random.choice(["administrator", "owner"]) + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + # Create a new version should make it available to the user + time.sleep(1) # minio stores datetimes with the precision of a second + document.content = "new content 1" + document.save() + + versions = document.get_versions_slice()["versions"] + assert len(versions) == 1 + + version_id = versions[0]["version_id"] + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + # 404 because the version was created before the user was given access to the document + assert response.status_code == 404 + + document.content = "new content 2" + document.save() + + versions = document.get_versions_slice()["versions"] + assert len(versions) == 2 + + version_id = versions[0]["version_id"] + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/", + ) + assert response.status_code == 204 + + versions = document.get_versions_slice()["versions"] + assert len(versions) == 1 diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_ai_transform.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_ai_transform.py new file mode 100644 index 00000000..81b69174 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_ai_transform.py @@ -0,0 +1,356 @@ +""" +Test AI transform API endpoint for users in impress's core app. +""" + +import random +from unittest.mock import MagicMock, patch + +from django.test import override_settings + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def ai_settings(): + """Fixture to set AI settings.""" + with override_settings( + AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama" + ): + yield + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +@pytest.mark.parametrize( + "reach, role", + [ + ("restricted", "reader"), + ("restricted", "editor"), + ("authenticated", "reader"), + ("authenticated", "editor"), + ("public", "reader"), + ], +) +def test_api_documents_ai_transform_anonymous_forbidden(reach, role): + """ + Anonymous users should not be able to request AI transform if the link reach + and role don't allow it. + """ + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = APIClient().post(url, {"text": "hello", "action": "prompt"}) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@override_settings(AI_ALLOW_REACH_FROM="public") +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_transform_anonymous_success(mock_create): + """ + Anonymous users should be able to request AI transform to a document + if the link reach and role permit it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = APIClient().post(url, {"text": "Hello", "action": "summarize"}) + + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + mock_create.assert_called_once_with( + model="llama", + messages=[ + { + "role": "system", + "content": ( + "Summarize the markdown text, preserving language and markdown formatting. " + "Do not provide any other information. Preserve the language." + ), + }, + {"role": "user", "content": "Hello"}, + ], + ) + + +@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"])) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create): + """ + Anonymous users should be able to request AI transform to a document + if the link reach and role permit it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + answer = '{"answer": "Salut"}' + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content=answer))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = APIClient().post(url, {"text": "Hello", "action": "summarize"}) + + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "reach, role", + [ + ("restricted", "reader"), + ("restricted", "editor"), + ("authenticated", "reader"), + ("public", "reader"), + ], +) +def test_api_documents_ai_transform_authenticated_forbidden(reach, role): + """ + Users who are not related to a document can't request AI transform if the + link reach and role don't allow it. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = client.post(url, {"text": "Hello", "action": "prompt"}) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize( + "reach, role", + [ + ("authenticated", "editor"), + ("public", "editor"), + ], +) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role): + """ + Authenticated who are not related to a document should be able to request AI transform + if the link reach and role permit it. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = client.post(url, {"text": "Hello", "action": "prompt"}) + + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + mock_create.assert_called_once_with( + model="llama", + messages=[ + { + "role": "system", + "content": ( + "Answer the prompt in markdown format. Preserve the language and markdown " + "formatting. Do not provide any other information. Preserve the language." + ), + }, + {"role": "user", "content": "Hello"}, + ], + ) + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_ai_transform_reader(via, mock_user_teams): + """ + Users who are simple readers on a document should not be allowed to request AI transform. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_role="reader") + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="reader") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="reader" + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = client.post(url, {"text": "Hello", "action": "prompt"}) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) +@pytest.mark.parametrize("via", VIA) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_teams): + """ + Editors, administrators and owners of a document should be able to request AI transform. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = client.post(url, {"text": "Hello", "action": "prompt"}) + + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + mock_create.assert_called_once_with( + model="llama", + messages=[ + { + "role": "system", + "content": ( + "Answer the prompt in markdown format. Preserve the language and markdown " + "formatting. Do not provide any other information. Preserve the language." + ), + }, + {"role": "user", "content": "Hello"}, + ], + ) + + +def test_api_documents_ai_transform_empty_text(): + """The text should not be empty when requesting AI transform.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = client.post(url, {"text": " ", "action": "prompt"}) + + assert response.status_code == 400 + assert response.json() == {"text": ["This field may not be blank."]} + + +def test_api_documents_ai_transform_invalid_action(): + """The action should valid when requesting AI transform.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = client.post(url, {"text": "Hello", "action": "invalid"}) + + assert response.status_code == 400 + assert response.json() == {"action": ['"invalid" is not a valid choice.']} + + +@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_transform_throttling_document(mock_create): + """ + Throttling per document should be triggered on the AI transform endpoint. + For full throttle class test see: `test_api_utils_ai_document_rate_throttles` + """ + client = APIClient() + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + for _ in range(3): + user = factories.UserFactory() + client.force_login(user) + response = client.post(url, {"text": "Hello", "action": "summarize"}) + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + + user = factories.UserFactory() + client.force_login(user) + response = client.post(url, {"text": "Hello", "action": "summarize"}) + + assert response.status_code == 429 + assert response.json() == { + "detail": "Request was throttled. Expected available in 60 seconds." + } + + +@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_transform_throttling_user(mock_create): + """ + Throttling per user should be triggered on the AI transform endpoint. + For full throttle class test see: `test_api_utils_ai_user_rate_throttles` + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + for _ in range(3): + document = factories.DocumentFactory(link_reach="public", link_role="editor") + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = client.post(url, {"text": "Hello", "action": "summarize"}) + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = client.post(url, {"text": "Hello", "action": "summarize"}) + + assert response.status_code == 429 + assert response.json() == { + "detail": "Request was throttled. Expected available in 60 seconds." + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_ai_translate.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_ai_translate.py new file mode 100644 index 00000000..f0d7978c --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_ai_translate.py @@ -0,0 +1,384 @@ +""" +Test AI translate API endpoint for users in impress's core app. +""" + +import random +from unittest.mock import MagicMock, patch + +from django.test import override_settings + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def ai_settings(): + """Fixture to set AI settings.""" + with override_settings( + AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama" + ): + yield + + +def test_api_documents_ai_translate_viewset_options_metadata(): + """The documents endpoint should give us the list of available languages.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + factories.DocumentFactory(link_reach="public", link_role="editor") + + response = APIClient().options("/api/v1.0/documents/") + + assert response.status_code == 200 + metadata = response.json() + assert metadata["name"] == "Document List" + assert metadata["actions"]["POST"]["language"]["choices"][0] == { + "value": "af", + "display_name": "Afrikaans", + } + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +@pytest.mark.parametrize( + "reach, role", + [ + ("restricted", "reader"), + ("restricted", "editor"), + ("authenticated", "reader"), + ("authenticated", "editor"), + ("public", "reader"), + ], +) +def test_api_documents_ai_translate_anonymous_forbidden(reach, role): + """ + Anonymous users should not be able to request AI translate if the link reach + and role don't allow it. + """ + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = APIClient().post(url, {"text": "hello", "language": "es"}) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@override_settings(AI_ALLOW_REACH_FROM="public") +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_translate_anonymous_success(mock_create): + """ + Anonymous users should be able to request AI translate to a document + if the link reach and role permit it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Ola"))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = APIClient().post(url, {"text": "Hello", "language": "es"}) + + assert response.status_code == 200 + assert response.json() == {"answer": "Ola"} + mock_create.assert_called_once_with( + model="llama", + messages=[ + { + "role": "system", + "content": ( + "Keep the same html structure and formatting. " + "Translate the content in the html to the specified language Spanish. " + "Check the translation for accuracy and make any necessary corrections. " + "Do not provide any other information." + ), + }, + {"role": "user", "content": "Hello"}, + ], + ) + + +@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"])) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create): + """ + Anonymous users should be able to request AI translate to a document + if the link reach and role permit it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + answer = '{"answer": "Salut"}' + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content=answer))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = APIClient().post(url, {"text": "Hello", "language": "es"}) + + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "reach, role", + [ + ("restricted", "reader"), + ("restricted", "editor"), + ("authenticated", "reader"), + ("public", "reader"), + ], +) +def test_api_documents_ai_translate_authenticated_forbidden(reach, role): + """ + Users who are not related to a document can't request AI translate if the + link reach and role don't allow it. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = client.post(url, {"text": "Hello", "language": "es"}) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize( + "reach, role", + [ + ("authenticated", "editor"), + ("public", "editor"), + ], +) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role): + """ + Authenticated who are not related to a document should be able to request AI translate + if the link reach and role permit it. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = client.post(url, {"text": "Hello", "language": "es-co"}) + + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + mock_create.assert_called_once_with( + model="llama", + messages=[ + { + "role": "system", + "content": ( + "Keep the same html structure and formatting. " + "Translate the content in the html to the " + "specified language Colombian Spanish. " + "Check the translation for accuracy and make any necessary corrections. " + "Do not provide any other information." + ), + }, + {"role": "user", "content": "Hello"}, + ], + ) + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_ai_translate_reader(via, mock_user_teams): + """ + Users who are simple readers on a document should not be allowed to request AI translate. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_role="reader") + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="reader") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="reader" + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = client.post(url, {"text": "Hello", "language": "es"}) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) +@pytest.mark.parametrize("via", VIA) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_teams): + """ + Editors, administrators and owners of a document should be able to request AI translate. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = client.post(url, {"text": "Hello", "language": "es-co"}) + + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + mock_create.assert_called_once_with( + model="llama", + messages=[ + { + "role": "system", + "content": ( + "Keep the same html structure and formatting. " + "Translate the content in the html to the " + "specified language Colombian Spanish. " + "Check the translation for accuracy and make any necessary corrections. " + "Do not provide any other information." + ), + }, + {"role": "user", "content": "Hello"}, + ], + ) + + +def test_api_documents_ai_translate_empty_text(): + """The text should not be empty when requesting AI translate.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = client.post(url, {"text": " ", "language": "es"}) + + assert response.status_code == 400 + assert response.json() == {"text": ["This field may not be blank."]} + + +def test_api_documents_ai_translate_invalid_action(): + """The action should valid when requesting AI translate.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = client.post(url, {"text": "Hello", "language": "invalid"}) + + assert response.status_code == 400 + assert response.json() == {"language": ['"invalid" is not a valid choice.']} + + +@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_translate_throttling_document(mock_create): + """ + Throttling per document should be triggered on the AI translate endpoint. + For full throttle class test see: `test_api_utils_ai_document_rate_throttles` + """ + client = APIClient() + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + for _ in range(3): + user = factories.UserFactory() + client.force_login(user) + response = client.post(url, {"text": "Hello", "language": "es"}) + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + + user = factories.UserFactory() + client.force_login(user) + response = client.post(url, {"text": "Hello", "language": "es"}) + + assert response.status_code == 429 + assert response.json() == { + "detail": "Request was throttled. Expected available in 60 seconds." + } + + +@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_translate_throttling_user(mock_create): + """ + Throttling per user should be triggered on the AI translate endpoint. + For full throttle class test see: `test_api_utils_ai_user_rate_throttles` + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + for _ in range(3): + document = factories.DocumentFactory(link_reach="public", link_role="editor") + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = client.post(url, {"text": "Hello", "language": "es"}) + assert response.status_code == 200 + assert response.json() == {"answer": "Salut"} + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = client.post(url, {"text": "Hello", "language": "es"}) + + assert response.status_code == 429 + assert response.json() == { + "detail": "Request was throttled. Expected available in 60 seconds." + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_attachment_upload.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_attachment_upload.py new file mode 100644 index 00000000..05fb5757 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_attachment_upload.py @@ -0,0 +1,441 @@ +""" +Test file uploads API endpoint for users in impress's core app. +""" + +import re +import uuid +from unittest import mock +from urllib.parse import parse_qs, urlparse + +from django.core.files.storage import default_storage +from django.core.files.uploadedfile import SimpleUploadedFile + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.api.viewsets import malware_detection +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + +PIXEL = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00" + b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe" + b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +@pytest.mark.parametrize( + "reach, role", + [ + ("restricted", "reader"), + ("restricted", "editor"), + ("authenticated", "reader"), + ("authenticated", "editor"), + ("public", "reader"), + ], +) +def test_api_documents_attachment_upload_anonymous_forbidden(reach, role): + """ + Anonymous users should not be able to upload attachments if the link reach + and role don't allow it. + """ + document = factories.DocumentFactory(link_reach=reach, link_role=role) + file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") + + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + response = APIClient().post(url, {"file": file}, format="multipart") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_documents_attachment_upload_anonymous_success(): + """ + Anonymous users should be able to upload attachments to a document + if the link reach and role permit it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") + + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = APIClient().post(url, {"file": file}, format="multipart") + + assert response.status_code == 201 + + pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png") + url_parsed = urlparse(response.json()["file"]) + assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/" + query = parse_qs(url_parsed.query) + assert query["key"][0] is not None + file_path = query["key"][0] + match = pattern.search(file_path) + file_id = match.group(1) + # Validate that file_id is a valid UUID + uuid.UUID(file_id) + + document.refresh_from_db() + assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"] + + # Now, check the metadata of the uploaded file + key = file_path.replace("/media/", "") + mock_analyse_file.assert_called_once_with(key, document_id=document.id) + file_head = default_storage.connection.meta.client.head_object( + Bucket=default_storage.bucket_name, Key=key + ) + + assert file_head["Metadata"] == {"owner": "None", "status": "processing"} + assert file_head["ContentType"] == "image/png" + assert file_head["ContentDisposition"] == 'inline; filename="test.png"' + + +@pytest.mark.parametrize( + "reach, role", + [ + ("restricted", "reader"), + ("restricted", "editor"), + ("authenticated", "reader"), + ("public", "reader"), + ], +) +def test_api_documents_attachment_upload_authenticated_forbidden(reach, role): + """ + Users who are not related to a document can't upload attachments if the + link reach and role don't allow it. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, link_role=role) + file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") + + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + response = client.post(url, {"file": file}, format="multipart") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + document.refresh_from_db() + assert document.attachments == [] + + +@pytest.mark.parametrize( + "reach, role", + [ + ("authenticated", "editor"), + ("public", "editor"), + ], +) +def test_api_documents_attachment_upload_authenticated_success(reach, role): + """ + Authenticated users who are not related to a document should be able to upload + a file when the link reach and role permit it. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, link_role=role) + file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") + + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(url, {"file": file}, format="multipart") + + assert response.status_code == 201 + + pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png") + url_parsed = urlparse(response.json()["file"]) + assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/" + query = parse_qs(url_parsed.query) + assert query["key"][0] is not None + file_path = query["key"][0] + match = pattern.search(file_path) + file_id = match.group(1) + + mock_analyse_file.assert_called_once_with( + f"{document.id!s}/attachments/{file_id!s}.png", document_id=document.id + ) + + # Validate that file_id is a valid UUID + uuid.UUID(file_id) + + document.refresh_from_db() + assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"] + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_attachment_upload_reader(via, mock_user_teams): + """ + Users who are simple readers on a document should not be allowed to upload an attachment. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_role="reader") + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="reader") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="reader" + ) + + file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") + + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + response = client.post(url, {"file": file}, format="multipart") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + document.refresh_from_db() + assert document.attachments == [] + + +@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) +@pytest.mark.parametrize("via", VIA) +def test_api_documents_attachment_upload_success(via, role, mock_user_teams): + """ + Editors, administrators and owners of a document should be able to upload an attachment. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") + + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(url, {"file": file}, format="multipart") + + assert response.status_code == 201 + + pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png") + url_parsed = urlparse(response.json()["file"]) + assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/" + query = parse_qs(url_parsed.query) + assert query["key"][0] is not None + file_path = query["key"][0] + match = pattern.search(file_path) + file_id = match.group(1) + + # Validate that file_id is a valid UUID + uuid.UUID(file_id) + + document.refresh_from_db() + assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"] + + # Now, check the metadata of the uploaded file + key = file_path.replace("/media/", "") + mock_analyse_file.assert_called_once_with(key, document_id=document.id) + file_head = default_storage.connection.meta.client.head_object( + Bucket=default_storage.bucket_name, Key=key + ) + assert file_head["Metadata"] == {"owner": str(user.id), "status": "processing"} + assert file_head["ContentType"] == "image/png" + assert file_head["ContentDisposition"] == 'inline; filename="test.png"' + + +def test_api_documents_attachment_upload_invalid(client): + """Attempt to upload without a file should return an explicit error.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[(user, "owner")]) + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + + response = client.post(url, {}, format="multipart") + + assert response.status_code == 400 + assert response.json() == {"file": ["No file was submitted."]} + + document.refresh_from_db() + assert document.attachments == [] + + +def test_api_documents_attachment_upload_size_limit_exceeded(settings): + """The uploaded file should not exceed the maximum size in settings.""" + settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[(user, "owner")]) + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + + # Create a temporary file larger than the allowed size + file = SimpleUploadedFile( + name="test.txt", content=b"a" * (1048576 + 1), content_type="text/plain" + ) + + response = client.post(url, {"file": file}, format="multipart") + + assert response.status_code == 400 + assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]} + + document.refresh_from_db() + assert document.attachments == [] + + +@pytest.mark.parametrize( + "name,content,extension,content_type", + [ + ("test.exe", b"text", "exe", "text/plain"), + ("test", b"text", "txt", "text/plain"), + ("test.aaaaaa", b"test", "txt", "text/plain"), + ("test.txt", PIXEL, "txt", "image/png"), + ("test.py", b"#!/usr/bin/python", "py", "text/plain"), + ], +) +def test_api_documents_attachment_upload_fix_extension( + name, content, extension, content_type +): + """ + A file with no extension or a wrong extension is accepted and the extension + is corrected in storage. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[(user, "owner")]) + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + + file = SimpleUploadedFile(name=name, content=content) + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(url, {"file": file}, format="multipart") + + assert response.status_code == 201 + + pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.{extension:s}") + url_parsed = urlparse(response.json()["file"]) + assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/" + query = parse_qs(url_parsed.query) + assert query["key"][0] is not None + file_path = query["key"][0] + + match = pattern.search(file_path) + file_id = match.group(1) + + document.refresh_from_db() + assert document.attachments == [ + f"{document.id!s}/attachments/{file_id!s}.{extension:s}" + ] + + assert "-unsafe" in file_id + # Validate that file_id is a valid UUID + file_id = file_id.replace("-unsafe", "") + uuid.UUID(file_id) + + # Now, check the metadata of the uploaded file + key = file_path.replace("/media/", "") + mock_analyse_file.assert_called_once_with(key, document_id=document.id) + file_head = default_storage.connection.meta.client.head_object( + Bucket=default_storage.bucket_name, Key=key + ) + assert file_head["Metadata"] == { + "owner": str(user.id), + "is_unsafe": "true", + "status": "processing", + } + assert file_head["ContentType"] == content_type + assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"' + + +def test_api_documents_attachment_upload_empty_file(): + """An empty file should be rejected.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[(user, "owner")]) + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + + file = SimpleUploadedFile(name="test.png", content=b"") + response = client.post(url, {"file": file}, format="multipart") + + assert response.status_code == 400 + assert response.json() == {"file": ["The submitted file is empty."]} + + document.refresh_from_db() + assert document.attachments == [] + + +def test_api_documents_attachment_upload_unsafe(): + """A file with an unsafe mime type should be tagged as such.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[(user, "owner")]) + url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" + + file = SimpleUploadedFile( + name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00" + ) + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(url, {"file": file}, format="multipart") + + assert response.status_code == 201 + + pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.exe") + url_parsed = urlparse(response.json()["file"]) + assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/" + query = parse_qs(url_parsed.query) + assert query["key"][0] is not None + file_path = query["key"][0] + match = pattern.search(file_path) + file_id = match.group(1) + + document.refresh_from_db() + assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"] + + assert "-unsafe" in file_id + # Validate that file_id is a valid UUID + file_id = file_id.replace("-unsafe", "") + uuid.UUID(file_id) + + key = file_path.replace("/media/", "") + mock_analyse_file.assert_called_once_with(key, document_id=document.id) + # Now, check the metadata of the uploaded file + file_head = default_storage.connection.meta.client.head_object( + Bucket=default_storage.bucket_name, Key=key + ) + assert file_head["Metadata"] == { + "owner": str(user.id), + "is_unsafe": "true", + "status": "processing", + } + # Depending the libmagic version, the content type may change. + assert file_head["ContentType"] in [ + "application/x-dosexec", + "application/octet-stream", + ] + assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"' diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_children_create.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_children_create.py new file mode 100644 index 00000000..5aea1b60 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_children_create.py @@ -0,0 +1,290 @@ +""" +Tests for Documents API endpoint in impress's core app: children create +""" + +from concurrent.futures import ThreadPoolExecutor +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Document, LinkReachChoices, LinkRoleChoices + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("role", LinkRoleChoices.values) +@pytest.mark.parametrize("reach", LinkReachChoices.values) +def test_api_documents_children_create_anonymous(reach, role, depth): + """Anonymous users should not be allowed to create children documents.""" + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + else: + document = factories.DocumentFactory(parent=document) + + response = APIClient().post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my document", + }, + ) + + assert Document.objects.count() == depth + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize( + "reach,role", + [ + ["restricted", "editor"], + ["restricted", "reader"], + ["public", "reader"], + ["authenticated", "reader"], + ], +) +def test_api_documents_children_create_authenticated_forbidden(reach, role, depth): + """ + Authenticated users with no write access on a document should not be allowed + to create a nested document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + else: + document = factories.DocumentFactory(parent=document, link_role="reader") + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my document", + }, + ) + + assert response.status_code == 403 + assert Document.objects.count() == depth + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize( + "reach,role", + [ + ["public", "editor"], + ["authenticated", "editor"], + ], +) +def test_api_documents_children_create_authenticated_success(reach, role, depth): + """ + Authenticated users with write access on a document should be able + to create a nested document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + else: + document = factories.DocumentFactory(parent=document, link_role="reader") + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my child", + }, + ) + + assert response.status_code == 201 + + child = Document.objects.get(id=response.json()["id"]) + assert child.title == "my child" + assert child.link_reach == "restricted" + assert child.accesses.filter(role="owner", user=user).exists() + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +def test_api_documents_children_create_related_forbidden(depth): + """ + Authenticated users with a specific read access on a document should not be allowed + to create a nested document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach="restricted") + factories.UserDocumentAccessFactory( + user=user, document=document, role="reader" + ) + else: + document = factories.DocumentFactory( + parent=document, link_reach="restricted" + ) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my document", + }, + ) + + assert response.status_code == 403 + assert Document.objects.count() == depth + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) +def test_api_documents_children_create_related_success(role, depth): + """ + Authenticated users with a specific write access on a document should be + able to create a nested document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach="restricted") + factories.UserDocumentAccessFactory(user=user, document=document, role=role) + else: + document = factories.DocumentFactory( + parent=document, link_reach="restricted" + ) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my child", + }, + ) + + assert response.status_code == 201 + child = Document.objects.get(id=response.json()["id"]) + assert child.title == "my child" + assert child.link_reach == "restricted" + assert child.accesses.filter(role="owner", user=user).exists() + + +def test_api_documents_children_create_authenticated_title_null(): + """It should be possible to create several nested documents with a null title.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory( + title=None, link_reach="authenticated", link_role="editor" + ) + factories.DocumentFactory(title=None, parent=parent) + + response = client.post( + f"/api/v1.0/documents/{parent.id!s}/children/", {}, format="json" + ) + + assert response.status_code == 201 + assert Document.objects.filter(title__isnull=True).count() == 3 + + +def test_api_documents_children_create_force_id_success(): + """It should be possible to force the document ID when creating a nested document.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory(user=user, role="editor") + forced_id = uuid4() + + response = client.post( + f"/api/v1.0/documents/{access.document.id!s}/children/", + { + "id": str(forced_id), + "title": "my document", + }, + format="json", + ) + + assert response.status_code == 201 + assert Document.objects.count() == 2 + assert response.json()["id"] == str(forced_id) + + +def test_api_documents_children_create_force_id_existing(): + """ + It should not be possible to use the ID of an existing document when forcing ID on creation. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory(user=user, role="editor") + document = factories.DocumentFactory() + + response = client.post( + f"/api/v1.0/documents/{access.document.id!s}/children/", + { + "id": str(document.id), + "title": "my document", + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "id": ["A document with this ID already exists. You cannot override it."] + } + + +@pytest.mark.django_db(transaction=True) +def test_api_documents_create_document_children_race_condition(): + """ + It should be possible to create several documents at the same time + without causing any race conditions or data integrity issues. + """ + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + + factories.UserDocumentAccessFactory(user=user, document=document, role="owner") + + def create_document(): + return client.post( + f"/api/v1.0/documents/{document.id}/children/", + { + "title": "my child", + }, + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(create_document) + future2 = executor.submit(create_document) + + response1 = future1.result() + response2 = future2.result() + + assert response1.status_code == 201 + assert response2.status_code == 201 + + document.refresh_from_db() + assert document.numchild == 2 diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_children_list.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_children_list.py new file mode 100644 index 00000000..96e1d9b4 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -0,0 +1,555 @@ +""" +Tests for Documents API endpoint in impress's core app: children list +""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_children_list_anonymous_public_standalone(): + """Anonymous users should be allowed to retrieve the children of a public document.""" + document = factories.DocumentFactory(link_reach="public") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +def test_api_documents_children_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the children of a document who + has a public ancestor. + """ + grand_parent = factories.DocumentFactory(link_reach="public") + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach): + """ + Anonymous users should not be able to retrieve children of a document that is not public. + """ + document = factories.DocumentFactory(link_reach=reach) + factories.DocumentFactory.create_batch(2, parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated( + reach, +): + """ + Authenticated users should be able to retrieve the children of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_children_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the children of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach=reach) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +def test_api_documents_children_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the children of a document that is + restricted and to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_children_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the children of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = factories.UserDocumentAccessFactory(document=document, user=user) + factories.UserDocumentAccessFactory(document=document) + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + } + + +def test_api_documents_children_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the children of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach="restricted") + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + grand_parent_access = factories.UserDocumentAccessFactory( + document=grand_parent, user=user + ) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + ], + } + + +def test_api_documents_children_list_authenticated_related_child(): + """ + Authenticated users should not be allowed to retrieve all the children of a document + as a result of being related to one of its children. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + + factories.UserDocumentAccessFactory(document=child1, user=user) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams): + """ + Authenticated users should not be able to retrieve the children of a restricted document + related to teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + factories.DocumentFactory.create_batch(2, parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_children_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the children of a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = ["myteam"] + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + + access = factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_cors_proxy.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_cors_proxy.py new file mode 100644 index 00000000..8f5d4219 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_cors_proxy.py @@ -0,0 +1,121 @@ +"""Test on the CORS proxy API for documents.""" + +import pytest +import responses +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +@responses.activate +def test_api_docs_cors_proxy_valid_url(): + """Test the CORS proxy API for documents with a valid URL.""" + document = factories.DocumentFactory(link_reach="public") + + client = APIClient() + url_to_fetch = "https://external-url.com/assets/logo-gouv.png" + responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" + ) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "image/png" + assert response.headers["Content-Disposition"] == "attachment;" + assert ( + response.headers["Content-Security-Policy"] + == "default-src 'none'; img-src 'none' data:;" + ) + assert response.streaming_content + + +def test_api_docs_cors_proxy_without_url_query_string(): + """Test the CORS proxy API for documents without a URL query string.""" + document = factories.DocumentFactory(link_reach="public") + + client = APIClient() + response = client.get(f"/api/v1.0/documents/{document.id!s}/cors-proxy/") + assert response.status_code == 400 + assert response.json() == {"detail": "Missing 'url' query parameter"} + + +@responses.activate +def test_api_docs_cors_proxy_anonymous_document_not_public(): + """Test the CORS proxy API for documents with an anonymous user and a non-public document.""" + document = factories.DocumentFactory(link_reach="authenticated") + + client = APIClient() + url_to_fetch = "https://external-url.com/assets/logo-gouv.png" + responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@responses.activate +def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(): + """ + Test the CORS proxy API for documents with an authenticated user accessing a protected + document. + """ + document = factories.DocumentFactory(link_reach="authenticated") + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + url_to_fetch = "https://external-url.com/assets/logo-gouv.png" + responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" + ) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "image/png" + assert response.headers["Content-Disposition"] == "attachment;" + assert ( + response.headers["Content-Security-Policy"] + == "default-src 'none'; img-src 'none' data:;" + ) + assert response.streaming_content + + +@responses.activate +def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(): + """ + Test the CORS proxy API for documents with an authenticated user not accessing a restricted + document. + """ + document = factories.DocumentFactory(link_reach="restricted") + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + url_to_fetch = "https://external-url.com/assets/logo-gouv.png" + responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@responses.activate +def test_api_docs_cors_proxy_unsupported_media_type(): + """Test the CORS proxy API for documents with an unsupported media type.""" + document = factories.DocumentFactory(link_reach="public") + + client = APIClient() + url_to_fetch = "https://external-url.com/assets/index.html" + responses.get(url_to_fetch, body=b"", status=200, content_type="text/html") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" + ) + assert response.status_code == 415 diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_create.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_create.py new file mode 100644 index 00000000..2b6c404d --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_create.py @@ -0,0 +1,145 @@ +""" +Tests for Documents API endpoint in impress's core app: create +""" + +from concurrent.futures import ThreadPoolExecutor +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Document + +pytestmark = pytest.mark.django_db + + +def test_api_documents_create_anonymous(): + """Anonymous users should not be allowed to create documents.""" + response = APIClient().post( + "/api/v1.0/documents/", + { + "title": "my document", + }, + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +def test_api_documents_create_authenticated_success(): + """ + Authenticated users should be able to create documents and should automatically be declared + as the owner of the newly created document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/documents/", + { + "title": "my document", + }, + format="json", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "my document" + assert document.link_reach == "restricted" + assert document.accesses.filter(role="owner", user=user).exists() + + +@pytest.mark.django_db(transaction=True) +def test_api_documents_create_document_race_condition(): + """ + It should be possible to create several documents at the same time + without causing any race conditions or data integrity issues. + """ + + def create_document(title): + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + return client.post( + "/api/v1.0/documents/", + { + "title": title, + }, + format="json", + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(create_document, "my document 1") + future2 = executor.submit(create_document, "my document 2") + + response1 = future1.result() + response2 = future2.result() + + assert response1.status_code == 201 + assert response2.status_code == 201 + + +def test_api_documents_create_authenticated_title_null(): + """It should be possible to create several documents with a null title.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + factories.DocumentFactory(title=None) + + response = client.post("/api/v1.0/documents/", {}, format="json") + + assert response.status_code == 201 + assert Document.objects.filter(title__isnull=True).count() == 2 + + +def test_api_documents_create_force_id_success(): + """It should be possible to force the document ID when creating a document.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + forced_id = uuid4() + + response = client.post( + "/api/v1.0/documents/", + { + "id": str(forced_id), + "title": "my document", + }, + format="json", + ) + + assert response.status_code == 201 + documents = Document.objects.all() + assert len(documents) == 1 + assert documents[0].id == forced_id + + +def test_api_documents_create_force_id_existing(): + """ + It should not be possible to use the ID of an existing document when forcing ID on creation. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + + response = client.post( + "/api/v1.0/documents/", + { + "id": str(document.id), + "title": "my document", + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "id": ["A document with this ID already exists. You cannot override it."] + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_create_for_owner.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_create_for_owner.py new file mode 100644 index 00000000..b2a76e55 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_create_for_owner.py @@ -0,0 +1,606 @@ +""" +Tests for Documents API endpoint in impress's core app: create +""" + +# pylint: disable=W0621 + +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import patch + +from django.core import mail +from django.test import override_settings + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.api.serializers import ServerCreateDocumentSerializer +from core.models import Document, Invitation, User +from core.services.converter_services import ConversionError, YdocConverter + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def mock_convert_md(): + """Mock YdocConverter.convert_markdown to return a converted content.""" + with patch.object( + YdocConverter, + "convert_markdown", + return_value="Converted document content", + ) as mock: + yield mock + + +def test_api_documents_create_for_owner_missing_token(): + """Requests with no token should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", data, format="json" + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_invalid_token(): + """Requests with an invalid token should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + "language": "fr", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer InvalidToken", + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +def test_api_documents_create_for_owner_authenticated_forbidden(): + """ + Authenticated users should not be allowed to call create documents on behalf of other users. + This API endpoint is reserved for server-to-server calls. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + } + + response = client.post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_missing_sub(): + """Requests with no sub should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "email": "john.doe@example.com", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 400 + assert not Document.objects.exists() + + assert response.json() == {"sub": ["This field is required."]} + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_missing_email(): + """Requests with no email should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 400 + assert not Document.objects.exists() + + assert response.json() == {"email": ["This field is required."]} + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_invalid_sub(): + """Requests with an invalid sub should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123!!", + "email": "john.doe@example.com", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 400 + assert not Document.objects.exists() + + assert response.json() == { + "sub": [ + "Enter a valid sub. This value may contain only letters, " + "numbers, and @/./+/-/_/: characters." + ] + } + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_existing(mock_convert_md): + """ + It should be possible to create a document on behalf of a pre-existing user + by passing their sub and email. + """ + user = factories.UserFactory(language="en-us") + + data = { + "title": "My Document", + "content": "Document content", + "sub": str(user.sub), + "email": "irrelevant@example.com", # Should be ignored since the user already exists + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_md.assert_called_once_with("Document content") + + document = Document.objects.get() + assert response.json() == {"id": str(document.id)} + + assert document.title == "My Document" + assert document.content == "Converted document content" + assert document.creator == user + assert document.accesses.filter(user=user, role="owner").exists() + + assert Invitation.objects.exists() is False + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == [user.email] + assert email.subject == "A new document was created on your behalf!" + email_content = " ".join(email.body.split()) + assert "A new document was created on your behalf!" in email_content + assert ( + "You have been granted ownership of a new document: My Document" + ) in email_content + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_new_user(mock_convert_md): + """ + It should be possible to create a document on behalf of new users by + passing their unknown sub and email address. + """ + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", # Should be used to create a new user + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_md.assert_called_once_with("Document content") + + document = Document.objects.get() + assert response.json() == {"id": str(document.id)} + + assert document.title == "My Document" + assert document.content == "Converted document content" + assert document.creator is None + assert document.accesses.exists() is False + + invitation = Invitation.objects.get() + assert invitation.email == "john.doe@example.com" + assert invitation.role == "owner" + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["john.doe@example.com"] + assert email.subject == "A new document was created on your behalf!" + email_content = " ".join(email.body.split()) + assert "A new document was created on your behalf!" in email_content + assert ( + "You have been granted ownership of a new document: My Document" + ) in email_content + + # The creator field on the document should be set when the user is created + user = User.objects.create(email="john.doe@example.com", password="!") + document.refresh_from_db() + assert document.creator == user + + +@override_settings( + SERVER_TO_SERVER_API_TOKENS=["DummyToken"], + OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True, +) +def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback( + mock_convert_md, +): + """ + It should be possible to create a document on behalf of a pre-existing user for + who the sub was not found if the settings allow it. This edge case should not + happen in a healthy OIDC federation but can be useful if an OIDC provider modifies + users sub on each login for example... + """ + user = factories.UserFactory(language="en-us") + + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": user.email, + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_md.assert_called_once_with("Document content") + + document = Document.objects.get() + assert response.json() == {"id": str(document.id)} + + assert document.title == "My Document" + assert document.content == "Converted document content" + assert document.creator == user + assert document.accesses.filter(user=user, role="owner").exists() + + assert Invitation.objects.exists() is False + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == [user.email] + assert email.subject == "A new document was created on your behalf!" + email_content = " ".join(email.body.split()) + assert "A new document was created on your behalf!" in email_content + assert ( + "You have been granted ownership of a new document: My Document" + ) in email_content + + +@override_settings( + SERVER_TO_SERVER_API_TOKENS=["DummyToken"], + OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False, + OIDC_ALLOW_DUPLICATE_EMAILS=False, +) +def test_api_documents_create_for_owner_existing_user_email_no_sub_no_fallback( + mock_convert_md, +): + """ + When a user does not match an existing sub and fallback to matching on email is + not allowed in settings, it should raise an error if the email is already used by + a registered user and duplicate emails are not allowed. + """ + user = factories.UserFactory() + + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": user.email, + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + assert response.status_code == 400 + assert response.json() == { + "email": [ + ( + "We couldn't find a user with this sub but the email is already " + "associated with a registered user." + ) + ] + } + assert mock_convert_md.called is False + assert Document.objects.exists() is False + assert Invitation.objects.exists() is False + assert len(mail.outbox) == 0 + + +@override_settings( + SERVER_TO_SERVER_API_TOKENS=["DummyToken"], + OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False, + OIDC_ALLOW_DUPLICATE_EMAILS=True, +) +def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplicate( + mock_convert_md, +): + """ + When a user does not match an existing sub and fallback to matching on email is + not allowed in settings, it should be possible to create a new user with the same + email as an existing user if the settings allow it (identification is still done + via the sub in this case). + """ + user = factories.UserFactory() + + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": user.email, + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + assert response.status_code == 201 + mock_convert_md.assert_called_once_with("Document content") + + document = Document.objects.get() + assert response.json() == {"id": str(document.id)} + + assert document.title == "My Document" + assert document.content == "Converted document content" + assert document.creator is None + assert document.accesses.exists() is False + + invitation = Invitation.objects.get() + assert invitation.email == user.email + assert invitation.role == "owner" + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == [user.email] + assert email.subject == "A new document was created on your behalf!" + email_content = " ".join(email.body.split()) + assert "A new document was created on your behalf!" in email_content + assert ( + "You have been granted ownership of a new document: My Document" + ) in email_content + + # The creator field on the document should be set when the user is created + user = User.objects.create(email=user.email, password="!") + document.refresh_from_db() + assert document.creator == user + + +@pytest.mark.django_db(transaction=True) +def test_api_documents_create_document_race_condition(): + """ + It should be possible to create several documents at the same time + without causing any race conditions or data integrity issues. + """ + + def create_document(title): + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + return client.post( + "/api/v1.0/documents/", + { + "title": title, + }, + format="json", + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(create_document, "my document 1") + future2 = executor.submit(create_document, "my document 2") + + response1 = future1.result() + response2 = future2.result() + + assert response1.status_code == 201 + assert response2.status_code == 201 + + +@patch.object(ServerCreateDocumentSerializer, "_send_email_notification") +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de") +def test_api_documents_create_for_owner_with_default_language( + mock_send, mock_convert_md +): + """The default language from settings should apply by default.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + assert response.status_code == 201 + + mock_convert_md.assert_called_once_with("Document content") + assert mock_send.call_args[0][3] == "de-de" + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_with_custom_language(mock_convert_md): + """ + Test creating a document with a specific language. + Useful if the remote server knows the user's language. + """ + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + "language": "fr-fr", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_md.assert_called_once_with("Document content") + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["john.doe@example.com"] + assert email.subject == "Un nouveau document a été créé pour vous !" + email_content = " ".join(email.body.split()) + assert "Un nouveau document a été créé pour vous !" in email_content + assert ( + "Vous avez été déclaré propriétaire d'un nouveau document : My Document" + ) in email_content + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_with_custom_subject_and_message( + mock_convert_md, +): + """It should be possible to customize the subject and message of the invitation email.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + "message": "mon message spécial", + "subject": "mon sujet spécial !", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_md.assert_called_once_with("Document content") + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["john.doe@example.com"] + assert email.subject == "Mon sujet spécial !" + email_content = " ".join(email.body.split()) + assert "Mon sujet spécial !" in email_content + assert "Mon message spécial" in email_content + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_with_converter_exception( + mock_convert_md, +): + """In case of converter error, a 400 error should be raised.""" + + mock_convert_md.side_effect = ConversionError("Conversion failed") + + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + "message": "mon message spécial", + "subject": "mon sujet spécial !", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + mock_convert_md.assert_called_once_with("Document content") + + assert response.status_code == 400 + assert response.json() == {"content": ["Could not convert content"]} + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_with_empty_content(): + """The content should not be empty or a 400 error should be raised.""" + + data = { + "title": "My Document", + "content": " ", + "sub": "123", + "email": "john.doe@example.com", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 400 + assert response.json() == { + "content": [ + "This field may not be blank.", + ], + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_delete.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_delete.py new file mode 100644 index 00000000..776bbe1f --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_delete.py @@ -0,0 +1,139 @@ +""" +Tests for Documents API endpoint in impress's core app: delete +""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_documents_delete_anonymous(): + """Anonymous users should not be allowed to destroy a document.""" + document = factories.DocumentFactory() + + response = APIClient().delete( + f"/api/v1.0/documents/{document.id!s}/", + ) + + assert response.status_code == 401 + assert models.Document.objects.count() == 1 + + +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +@pytest.mark.parametrize("role", models.LinkRoleChoices.values) +def test_api_documents_delete_authenticated_unrelated(reach, role): + """ + Authenticated users should not be allowed to delete a document to which + they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/", + ) + + assert response.status_code == 403 + assert models.Document.objects.count() == 2 + + +@pytest.mark.parametrize("role", ["reader", "editor", "administrator"]) +@pytest.mark.parametrize("via", VIA) +def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams): + """ + Authenticated users should not be allowed to delete a document for which they are + only a reader, editor or administrator. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.Document.objects.count() == 2 + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +def test_api_documents_delete_authenticated_owner_of_ancestor(depth): + """ + Authenticated users should not be able to delete a document for which + they are only owner of an ancestor. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + documents = [] + for i in range(depth): + documents.append( + factories.UserDocumentAccessFactory(role="owner", user=user).document + if i == 0 + else factories.DocumentFactory(parent=documents[-1]) + ) + assert models.Document.objects.count() == depth + + response = client.delete( + f"/api/v1.0/documents/{documents[-1].id}/", + ) + + assert response.status_code == 204 + + # Make sure it is only a soft delete + assert models.Document.objects.count() == depth + assert models.Document.objects.filter(deleted_at__isnull=True).count() == depth - 1 + assert models.Document.objects.filter(deleted_at__isnull=False).count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_delete_authenticated_owner(via, mock_user_teams): + """ + Authenticated users should be able to delete a document they own. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/", + ) + + assert response.status_code == 204 + + # Make sure it is only a soft delete + assert models.Document.objects.count() == 1 + assert models.Document.objects.filter(deleted_at__isnull=True).exists() is False + assert models.Document.objects.filter(deleted_at__isnull=False).count() == 1 diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_descendants.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_descendants.py new file mode 100644 index 00000000..302af231 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -0,0 +1,696 @@ +""" +Tests for Documents API endpoint in impress's core app: descendants +""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_descendants_list_anonymous_public_standalone(): + """Anonymous users should be allowed to retrieve the descendants of a public document.""" + document = factories.DocumentFactory(link_reach="public") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(AnonymousUser()), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +def test_api_documents_descendants_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the descendants of a document who + has a public ancestor. + """ + grand_parent = factories.DocumentFactory(link_reach="public") + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(AnonymousUser()), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(reach): + """ + Anonymous users should not be able to retrieve descendants of a document that is not public. + """ + document = factories.DocumentFactory(link_reach=reach) + child = factories.DocumentFactory(parent=document) + _grand_child = factories.DocumentFactory(parent=child) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_descendants_list_authenticated_unrelated_public_or_authenticated( + reach, +): + """ + Authenticated users should be able to retrieve the descendants of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_descendants_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the descendants of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach=reach) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the descendants of a document that is + restricted and to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + _grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the descendants of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = factories.UserDocumentAccessFactory(document=document, user=user) + factories.UserDocumentAccessFactory(document=document) + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + grand_child = factories.DocumentFactory(parent=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the descendants of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach="restricted") + grand_parent_access = factories.UserDocumentAccessFactory( + document=grand_parent, user=user + ) + + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + grand_child = factories.DocumentFactory(parent=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_related_child(): + """ + Authenticated users should not be allowed to retrieve all the descendants of a document + as a result of being related to one of its children. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + _grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1, user=user) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_team_none( + mock_user_teams, +): + """ + Authenticated users should not be able to retrieve the descendants of a restricted document + related to teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + factories.DocumentFactory.create_batch(2, parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the descendants of a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = ["myteam"] + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + access = factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_descendants_filters.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_descendants_filters.py new file mode 100644 index 00000000..342ead70 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_descendants_filters.py @@ -0,0 +1,95 @@ +""" +Tests for Documents API endpoint in impress's core app: list +""" + +import pytest +from faker import Faker +from rest_framework.test import APIClient + +from core import factories +from core.api.filters import remove_accents + +fake = Faker() +pytestmark = pytest.mark.django_db + + +# Filters: unknown field + + +def test_api_documents_descendants_filter_unknown_field(): + """ + Trying to filter by an unknown field should be ignored. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory() + + document = factories.DocumentFactory(users=[user]) + expected_ids = { + str(document.id) + for document in factories.DocumentFactory.create_batch(2, parent=document) + } + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true" + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + assert {result["id"] for result in results} == expected_ids + + +# Filters: title + + +@pytest.mark.parametrize( + "query,nb_results", + [ + ("Project Alpha", 1), # Exact match + ("project", 2), # Partial match (case-insensitive) + ("Guide", 2), # Word match within a title + ("Special", 0), # No match (nonexistent keyword) + ("2024", 2), # Match by numeric keyword + ("", 6), # Empty string + ("velo", 1), # Accent-insensitive match (velo vs vélo) + ("bêta", 1), # Accent-insensitive match (bêta vs beta) + ], +) +def test_api_documents_descendants_filter_title(query, nb_results): + """Authenticated users should be able to search documents by their unaccented title.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[user]) + + # Create documents with predefined titles + titles = [ + "Project Alpha Documentation", + "Project Beta Overview", + "User Guide", + "Financial Report 2024", + "Annual Review 2024", + "Guide du vélo urbain", # <-- Title with accent for accent-insensitive test + ] + for title in titles: + factories.DocumentFactory(title=title, parent=document) + + # Perform the search query + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}" + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == nb_results + + # Ensure all results contain the query in their title + for result in results: + assert ( + remove_accents(query).lower().strip() + in remove_accents(result["title"]).lower() + ) diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_duplicate.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_duplicate.py new file mode 100644 index 00000000..82acfa98 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_duplicate.py @@ -0,0 +1,207 @@ +""" +Test file uploads API endpoint for users in impress's core app. +""" + +import base64 +import uuid +from io import BytesIO +from urllib.parse import urlparse + +from django.conf import settings +from django.core.files.storage import default_storage +from django.utils import timezone + +import pycrdt +import pytest +import requests +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + +PIXEL = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00" + b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe" + b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +def get_image_refs(document_id): + """Generate an image key for testing.""" + image_key = f"{document_id!s}/attachments/{uuid.uuid4()!s}.png" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=image_key, + Body=BytesIO(PIXEL), + ContentType="image/png", + ) + return image_key, f"http://localhost/media/{image_key:s}" + + +def test_api_documents_duplicate_forbidden(): + """A user who doesn't have read access to a document should not be allowed to duplicate it.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory( + link_reach="restricted", + users=[factories.UserFactory()], + title="my document", + ) + + response = client.post(f"/api/v1.0/documents/{document.id!s}/duplicate/") + + assert response.status_code == 403 + assert models.Document.objects.count() == 1 + + +def test_api_documents_duplicate_anonymous(): + """Anonymous users should not be able to duplicate documents even with read access.""" + + document = factories.DocumentFactory(link_reach="public") + + response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/") + + assert response.status_code == 401 + assert models.Document.objects.count() == 1 + + +@pytest.mark.parametrize("index", range(3)) +def test_api_documents_duplicate_success(index): + """ + Anonymous users should be able to retrieve attachments linked to a public document. + Accesses should not be duplicated if the user does not request it specifically. + Attachments that are not in the content should not be passed for access in the + duplicated document's "attachments" list. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document_ids = [uuid.uuid4() for _ in range(3)] + image_refs = [get_image_refs(doc_id) for doc_id in document_ids] + + # Create document content with the first image only + ydoc = pycrdt.Doc() + fragment = pycrdt.XmlFragment( + [ + pycrdt.XmlElement("img", {"src": image_refs[0][1]}), + ] + ) + ydoc["document-store"] = fragment + update = ydoc.get_update() + base64_content = base64.b64encode(update).decode("utf-8") + + # Create documents + document = factories.DocumentFactory( + id=document_ids[index], + content=base64_content, + link_reach="restricted", + users=[user, factories.UserFactory()], + title="document with an image", + attachments=[key for key, _ in image_refs], + ) + factories.DocumentFactory(id=document_ids[(index + 1) % 3]) + # Don't create document for third ID to check that it doesn't impact access to attachments + + # Duplicate the document via the API endpoint + response = client.post(f"/api/v1.0/documents/{document.id}/duplicate/") + + assert response.status_code == 201 + + duplicated_document = models.Document.objects.get(id=response.json()["id"]) + assert duplicated_document.title == "Copy of document with an image" + assert duplicated_document.content == document.content + assert duplicated_document.creator == user + assert duplicated_document.link_reach == "restricted" + assert duplicated_document.link_role == "reader" + assert duplicated_document.duplicated_from == document + assert duplicated_document.attachments == [ + image_refs[0][0] + ] # Only the first image key + assert duplicated_document.get_parent() == document.get_parent() + assert duplicated_document.path == document.get_next_sibling().path + + # Check that accesses were not duplicated. + # The user who did the duplicate is forced as owner + assert duplicated_document.accesses.count() == 1 + access = duplicated_document.accesses.first() + assert access.user == user + assert access.role == "owner" + + # Ensure access persists after the owner loses access to the original document + models.DocumentAccess.objects.filter(document=document).delete() + response = client.get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1] + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + response = requests.get( + f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{image_refs[0][0]:s}", + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content == PIXEL + + # Ensure the other images are not accessible + for _, url in image_refs[1:]: + response = client.get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=url + ) + assert response.status_code == 403 + + +def test_api_documents_duplicate_with_accesses(): + """Accesses should be duplicated if the user requests it specifically.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory( + users=[user], + title="document with accesses", + ) + user_access = factories.UserDocumentAccessFactory(document=document) + team_access = factories.TeamDocumentAccessFactory(document=document) + + # Duplicate the document via the API endpoint requesting to duplicate accesses + response = client.post( + f"/api/v1.0/documents/{document.id!s}/duplicate/", + {"with_accesses": True}, + format="json", + ) + + assert response.status_code == 201 + + duplicated_document = models.Document.objects.get(id=response.json()["id"]) + assert duplicated_document.title == "Copy of document with accesses" + assert duplicated_document.content == document.content + assert duplicated_document.link_reach == document.link_reach + assert duplicated_document.link_role == document.link_role + assert duplicated_document.creator == user + assert duplicated_document.duplicated_from == document + assert duplicated_document.attachments == [] + + # Check that accesses were duplicated and the user who did the duplicate is forced as owner + duplicated_accesses = duplicated_document.accesses + assert duplicated_accesses.count() == 3 + assert duplicated_accesses.get(user=user).role == "owner" + assert duplicated_accesses.get(user=user_access.user).role == user_access.role + assert duplicated_accesses.get(team=team_access.team).role == team_access.role diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_favorite.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_favorite.py new file mode 100644 index 00000000..e0359b3d --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_favorite.py @@ -0,0 +1,308 @@ +"""Test favorite document API endpoint for users in impress's core app.""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + "reach", + [ + "restricted", + "authenticated", + "public", + ], +) +@pytest.mark.parametrize("method", ["post", "delete"]) +def test_api_document_favorite_anonymous_user(method, reach): + """Anonymous users should not be able to mark/unmark documents as favorites.""" + document = factories.DocumentFactory(link_reach=reach) + + response = getattr(APIClient(), method)( + f"/api/v1.0/documents/{document.id!s}/favorite/" + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + # Verify in database + assert models.DocumentFavorite.objects.exists() is False + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_post_allowed(reach, has_role): + """Authenticated users should be able to mark a document as favorite using POST.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + # Mark as favorite + response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 201 + assert response.json() == {"detail": "Document marked as favorite"} + + # Verify in database + assert models.DocumentFavorite.objects.filter(document=document, user=user).exists() + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is True + + +def test_api_document_favorite_authenticated_post_forbidden(): + """Authenticated users should be able to mark a document as favorite using POST.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + client = APIClient() + client.force_login(user) + + # Try marking as favorite + response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is False + ) + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_post_already_favorited_allowed( + reach, has_role +): + """POST should not create duplicate favorites if already marked.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach, favorited_by=[user]) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + # Try to mark as favorite again + response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 200 + assert response.json() == {"detail": "Document already marked as favorite"} + + # Verify in database + assert models.DocumentFavorite.objects.filter(document=document, user=user).exists() + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is True + + +def test_api_document_favorite_authenticated_post_already_favorited_forbidden(): + """POST should not create duplicate favorites if already marked.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user]) + client = APIClient() + client.force_login(user) + + # Try to mark as favorite again + response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify in database + assert models.DocumentFavorite.objects.filter(document=document, user=user).exists() + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_delete_allowed(reach, has_role): + """Authenticated users should be able to unmark a document as favorite using DELETE.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach, favorited_by=[user]) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + # Unmark as favorite + response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/") + assert response.status_code == 204 + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is False + ) + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is False + + +def test_api_document_favorite_authenticated_delete_forbidden(): + """Authenticated users should be able to unmark a document as favorite using DELETE.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user]) + client = APIClient() + client.force_login(user) + + # Unmark as favorite + response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is True + ) + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_delete_not_favorited_allowed( + reach, has_role +): + """DELETE should be idempotent if the document is not marked as favorite.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + # Try to unmark as favorite when no favorite entry exists + response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 200 + assert response.json() == {"detail": "Document was already not marked as favorite"} + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is False + ) + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is False + + +def test_api_document_favorite_authenticated_delete_not_favorited_forbidden(): + """DELETE should be idempotent if the document is not marked as favorite.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + client = APIClient() + client.force_login(user) + + # Try to unmark as favorite when no favorite entry exists + response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is False + ) + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_post_unmark_then_mark_again_allowed( + reach, has_role +): + """A user should be able to mark, unmark, and mark a document again as favorite.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + url = f"/api/v1.0/documents/{document.id!s}/favorite/" + + # Mark as favorite + response = client.post(url) + assert response.status_code == 201 + + # Unmark as favorite + response = client.delete(url) + assert response.status_code == 204 + + # Mark as favorite again + response = client.post(url) + assert response.status_code == 201 + assert response.json() == {"detail": "Document marked as favorite"} + + # Verify in database + assert models.DocumentFavorite.objects.filter(document=document, user=user).exists() + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is True diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_favorite_list.py new file mode 100644 index 00000000..8791a6bf --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -0,0 +1,80 @@ +"""Test for the document favorite_list endpoint.""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_document_favorite_list_anonymous(): + """Anonymous users should receive a 401 error.""" + client = APIClient() + + response = client.get("/api/v1.0/documents/favorite_list/") + + assert response.status_code == 401 + + +def test_api_document_favorite_list_authenticated_no_favorite(): + """Authenticated users should receive an empty list.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + response = client.get("/api/v1.0/documents/favorite_list/") + + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +def test_api_document_favorite_list_authenticated_with_favorite(): + """Authenticated users with a favorite should receive the favorite.""" + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # User don't have access to this document, let say it had access and this access has been + # removed. It should not be in the favorite list anymore. + factories.DocumentFactory(favorited_by=[user]) + + document = factories.UserDocumentAccessFactory( + user=user, role=models.RoleChoices.READER, document__favorited_by=[user] + ).document + + response = client.get("/api/v1.0/documents/favorite_list/") + + assert response.status_code == 200 + assert response.json() == { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "abilities": document.get_abilities(user), + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "content": document.content, + "depth": document.depth, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": True, + "link_reach": document.link_reach, + "link_role": document.link_role, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "numchild": document.numchild, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": ["reader"], + } + ], + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_link_configuration.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_link_configuration.py new file mode 100644 index 00000000..76838805 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_link_configuration.py @@ -0,0 +1,160 @@ +"""Tests for link configuration of documents on API endpoint""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA +from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import + mock_reset_connections, +) + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize("role", models.LinkRoleChoices.values) +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_documents_link_configuration_update_anonymous(reach, role): + """Anonymous users should not be allowed to update a link configuration.""" + document = factories.DocumentFactory(link_reach=reach, link_role=role) + old_document_values = serializers.LinkDocumentSerializer(instance=document).data + + new_document_values = serializers.LinkDocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = APIClient().put( + f"/api/v1.0/documents/{document.id!s}/link-configuration/", + new_document_values, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + document.refresh_from_db() + document_values = serializers.LinkDocumentSerializer(instance=document).data + assert document_values == old_document_values + + +@pytest.mark.parametrize("role", models.LinkRoleChoices.values) +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_documents_link_configuration_update_authenticated_unrelated(reach, role): + """ + Authenticated users should not be allowed to update the link configuration for + a document to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, link_role=role) + old_document_values = serializers.LinkDocumentSerializer(instance=document).data + + new_document_values = serializers.LinkDocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/link-configuration/", + new_document_values, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + document.refresh_from_db() + document_values = serializers.LinkDocumentSerializer(instance=document).data + assert document_values == old_document_values + + +@pytest.mark.parametrize("role", ["editor", "reader"]) +@pytest.mark.parametrize("via", VIA) +def test_api_documents_link_configuration_update_authenticated_related_forbidden( + via, role, mock_user_teams +): + """ + Users who are readers or editors of a document should not be allowed to update + the link configuration. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + old_document_values = serializers.LinkDocumentSerializer(instance=document).data + + new_document_values = serializers.LinkDocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/link-configuration/", + new_document_values, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + document.refresh_from_db() + document_values = serializers.LinkDocumentSerializer(instance=document).data + assert document_values == old_document_values + + +@pytest.mark.parametrize("role", ["administrator", "owner"]) +@pytest.mark.parametrize("via", VIA) +def test_api_documents_link_configuration_update_authenticated_related_success( + via, + role, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + A user who is administrator or owner of a document should be allowed to update + the link configuration. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + new_document_values = serializers.LinkDocumentSerializer( + instance=factories.DocumentFactory() + ).data + + with mock_reset_connections(document.id): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/link-configuration/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + document = models.Document.objects.get(pk=document.pk) + document_values = serializers.LinkDocumentSerializer(instance=document).data + for key, value in document_values.items(): + assert value == new_document_values[key] diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_list.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_list.py new file mode 100644 index 00000000..1120123e --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_list.py @@ -0,0 +1,425 @@ +""" +Tests for Documents API endpoint in impress's core app: list +""" + +import random +from datetime import timedelta +from unittest import mock + +from django.utils import timezone + +import pytest +from faker import Faker +from rest_framework.pagination import PageNumberPagination +from rest_framework.test import APIClient + +from core import factories, models + +fake = Faker() +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize("role", models.LinkRoleChoices.values) +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_documents_list_anonymous(reach, role): + """ + Anonymous users should not be allowed to list documents whatever the + link reach and link role + """ + factories.DocumentFactory(link_reach=reach, link_role=role) + + response = APIClient().get("/api/v1.0/documents/") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 0 + + +def test_api_documents_list_format(): + """Validate the format of documents as returned by the list view.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + other_users = factories.UserFactory.create_batch(3) + document = factories.DocumentFactory( + users=factories.UserFactory.create_batch(2), + favorited_by=[user, *other_users], + link_traces=other_users, + ) + access = factories.UserDocumentAccessFactory(document=document, user=user) + + response = client.get("/api/v1.0/documents/") + + assert response.status_code == 200 + content = response.json() + results = content.pop("results") + assert content == { + "count": 1, + "next": None, + "previous": None, + } + assert len(results) == 1 + assert results[0] == { + "id": str(document.id), + "abilities": document.get_abilities(user), + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 1, + "excerpt": document.excerpt, + "is_favorite": True, + "link_reach": document.link_reach, + "link_role": document.link_role, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 3, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +# pylint: disable=too-many-locals +def test_api_documents_list_authenticated_direct(django_assert_num_queries): + """ + Authenticated users should be able to list documents they are a direct + owner/administrator/member of or documents that have a link reach other + than restricted. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document1, document2 = [ + access.document + for access in factories.UserDocumentAccessFactory.create_batch(2, user=user) + ] + + # Unrelated and untraced documents + for reach in models.LinkReachChoices: + for role in models.LinkRoleChoices: + factories.DocumentFactory(link_reach=reach, link_role=role) + + # Children of visible documents should not get listed even with a specific access + factories.DocumentFactory(parent=document1) + + child1_with_access = factories.DocumentFactory(parent=document1) + factories.UserDocumentAccessFactory(user=user, document=child1_with_access) + + middle_document = factories.DocumentFactory(parent=document2) + child2_with_access = factories.DocumentFactory(parent=middle_document) + factories.UserDocumentAccessFactory(user=user, document=child2_with_access) + + # Children of hidden documents should get listed when visible by the logged-in user + hidden_root = factories.DocumentFactory() + child3_with_access = factories.DocumentFactory(parent=hidden_root) + factories.UserDocumentAccessFactory(user=user, document=child3_with_access) + child4_with_access = factories.DocumentFactory(parent=hidden_root) + factories.UserDocumentAccessFactory(user=user, document=child4_with_access) + + # Documents that are soft deleted and children of a soft deleted document should not be listed + soft_deleted_document = factories.DocumentFactory(users=[user]) + child_of_soft_deleted_document = factories.DocumentFactory( + users=[user], + parent=soft_deleted_document, + ) + factories.DocumentFactory(users=[user], parent=child_of_soft_deleted_document) + soft_deleted_document.soft_delete() + + # Documents that are permanently deleted and children of a permanently deleted + # document should not be listed + permanently_deleted_document = factories.DocumentFactory(users=[user]) + child_of_permanently_deleted_document = factories.DocumentFactory( + users=[user], parent=permanently_deleted_document + ) + factories.DocumentFactory( + users=[user], parent=child_of_permanently_deleted_document + ) + + fourty_days_ago = timezone.now() - timedelta(days=40) + with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago): + permanently_deleted_document.soft_delete() + + expected_ids = { + str(document1.id), + str(document2.id), + str(child3_with_access.id), + str(child4_with_access.id), + } + + with django_assert_num_queries(12): + response = client.get("/api/v1.0/documents/") + + # nb_accesses should now be cached + with django_assert_num_queries(4): + response = client.get("/api/v1.0/documents/") + + assert response.status_code == 200 + results = response.json()["results"] + results_ids = {result["id"] for result in results} + assert expected_ids == results_ids + + +def test_api_documents_list_authenticated_via_team( + django_assert_num_queries, mock_user_teams +): + """ + Authenticated users should be able to list documents they are a + owner/administrator/member of via a team. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + mock_user_teams.return_value = ["team1", "team2", "unknown"] + + documents_team1 = [ + access.document + for access in factories.TeamDocumentAccessFactory.create_batch(2, team="team1") + ] + documents_team2 = [ + access.document + for access in factories.TeamDocumentAccessFactory.create_batch(3, team="team2") + ] + + expected_ids = {str(document.id) for document in documents_team1 + documents_team2} + + with django_assert_num_queries(14): + response = client.get("/api/v1.0/documents/") + + # nb_accesses should now be cached + with django_assert_num_queries(4): + response = client.get("/api/v1.0/documents/") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +def test_api_documents_list_authenticated_link_reach_restricted( + django_assert_num_queries, +): + """ + An authenticated user who has link traces to a document that is restricted should not + see it on the list view + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_traces=[user], link_reach="restricted") + + # Link traces for other documents or other users should not interfere + models.LinkTrace.objects.create(document=document, user=factories.UserFactory()) + other_document = factories.DocumentFactory(link_reach="public") + models.LinkTrace.objects.create(document=other_document, user=user) + + with django_assert_num_queries(6): + response = client.get("/api/v1.0/documents/") + + # nb_accesses should now be cached + with django_assert_num_queries(4): + response = client.get("/api/v1.0/documents/") + + assert response.status_code == 200 + results = response.json()["results"] + # Only the other document is returned but not the restricted document even though the user + # visited it earlier (probably b/c it previously had public or authenticated reach...) + assert len(results) == 1 + assert results[0]["id"] == str(other_document.id) + + +def test_api_documents_list_authenticated_link_reach_public_or_authenticated( + django_assert_num_queries, +): + """ + An authenticated user who has link traces to a document with public or authenticated + link reach should see it on the list view. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document1, document2 = [ + factories.DocumentFactory(link_traces=[user], link_reach=reach) + for reach in models.LinkReachChoices + if reach != "restricted" + ] + factories.DocumentFactory( + link_reach=random.choice(["public", "authenticated"]), + link_traces=[user], + parent=document1, + ) + + hidden_document = factories.DocumentFactory( + link_reach=random.choice(["public", "authenticated"]) + ) + visible_child = factories.DocumentFactory( + link_traces=[user], + link_reach=random.choice(["public", "authenticated"]), + parent=hidden_document, + ) + + expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)} + + with django_assert_num_queries(10): + response = client.get("/api/v1.0/documents/") + + # nb_accesses should now be cached + with django_assert_num_queries(4): + response = client.get("/api/v1.0/documents/") + + assert response.status_code == 200 + results = response.json()["results"] + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_documents_list_pagination( + _mock_page_size, +): + """Pagination should work as expected.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document_ids = [ + str(access.document_id) + for access in factories.UserDocumentAccessFactory.create_batch(3, user=user) + ] + + # Get page 1 + response = client.get( + "/api/v1.0/documents/", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/documents/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + document_ids.remove(item["id"]) + + # Get page 2 + response = client.get( + "/api/v1.0/documents/?page=2", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"] == "http://testserver/api/v1.0/documents/" + + assert len(content["results"]) == 1 + document_ids.remove(content["results"][0]["id"]) + assert document_ids == [] + + +def test_api_documents_list_pagination_force_page_size(): + """Page size can be set via querystring.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document_ids = [ + str(access.document_id) + for access in factories.UserDocumentAccessFactory.create_batch(3, user=user) + ] + + # Force page size + response = client.get( + "/api/v1.0/documents/?page_size=2", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/documents/?page=2&page_size=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + document_ids.remove(item["id"]) + + +def test_api_documents_list_authenticated_distinct(): + """A document with several related users should only be listed once.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + + document = factories.DocumentFactory(users=[user, other_user]) + + response = client.get( + "/api/v1.0/documents/", + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 1 + assert content["results"][0]["id"] == str(document.id) + + +def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries): + """ + Ensure that marking documents as favorite does not generate additional queries + when fetching the document list. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + special_documents = factories.DocumentFactory.create_batch(3, users=[user]) + factories.DocumentFactory.create_batch(2, users=[user]) + + url = "/api/v1.0/documents/" + with django_assert_num_queries(14): + response = client.get(url) + + # nb_accesses should now be cached + with django_assert_num_queries(4): + response = client.get(url) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + assert all(result["is_favorite"] is False for result in results) + + # Mark documents as favorite and check results again + for document in special_documents: + models.DocumentFavorite.objects.create(document=document, user=user) + + with django_assert_num_queries(4): + response = client.get(url) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + # Check if the "is_favorite" annotation is correctly set for the favorited documents + favorited_ids = {str(doc.id) for doc in special_documents} + for result in results: + if result["id"] in favorited_ids: + assert result["is_favorite"] is True + else: + assert result["is_favorite"] is False diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_list_filters.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_list_filters.py new file mode 100644 index 00000000..f93ad6d5 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_list_filters.py @@ -0,0 +1,356 @@ +""" +Tests for Documents API endpoint in impress's core app: list +""" + +import operator +import random +from urllib.parse import urlencode + +import pytest +from faker import Faker +from rest_framework.test import APIClient + +from core import factories, models + +fake = Faker() +pytestmark = pytest.mark.django_db + + +def test_api_documents_list_filter_and_access_rights(): + """Filtering on querystring parameters should respect access rights.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + + def random_favorited_by(): + return random.choice([[], [user], [other_user]]) + + # Documents that should be listed to this user + listed_documents = [ + factories.DocumentFactory( + link_reach="public", + link_traces=[user], + favorited_by=random_favorited_by(), + creator=random.choice([user, other_user]), + ), + factories.DocumentFactory( + link_reach="authenticated", + link_traces=[user], + favorited_by=random_favorited_by(), + creator=random.choice([user, other_user]), + ), + factories.DocumentFactory( + link_reach="restricted", + users=[user], + favorited_by=random_favorited_by(), + creator=random.choice([user, other_user]), + ), + ] + listed_ids = [str(doc.id) for doc in listed_documents] + word_list = [word for doc in listed_documents for word in doc.title.split(" ")] + + # Documents that should not be listed to this user + factories.DocumentFactory( + link_reach="public", + favorited_by=random_favorited_by(), + creator=random.choice([user, other_user]), + ) + factories.DocumentFactory( + link_reach="authenticated", + favorited_by=random_favorited_by(), + creator=random.choice([user, other_user]), + ) + factories.DocumentFactory( + link_reach="restricted", + favorited_by=random_favorited_by(), + creator=random.choice([user, other_user]), + ) + factories.DocumentFactory( + link_reach="restricted", + link_traces=[user], + favorited_by=random_favorited_by(), + creator=random.choice([user, other_user]), + ) + + filters = { + "link_reach": random.choice([None, *models.LinkReachChoices.values]), + "title": random.choice([None, *word_list]), + "favorite": random.choice([None, True, False]), + "creator": random.choice([None, user, other_user]), + "ordering": random.choice( + [ + None, + "created_at", + "-created_at", + "is_favorite", + "-is_favorite", + "title", + "-title", + "updated_at", + "-updated_at", + ] + ), + } + query_params = {key: value for key, value in filters.items() if value is not None} + querystring = urlencode(query_params) + + response = client.get(f"/api/v1.0/documents/?{querystring:s}") + + assert response.status_code == 200 + results = response.json()["results"] + + # Ensure all documents in results respect expected access rights + for result in results: + assert result["id"] in listed_ids + + +# Filters: ordering + + +def test_api_documents_list_ordering_default(): + """Documents should be ordered by descending "updated_at" by default""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(5, users=[user]) + + response = client.get("/api/v1.0/documents/") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + # Check that results are sorted by descending "updated_at" as expected + for i in range(4): + assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"]) + + +def test_api_documents_list_ordering_by_fields(): + """It should be possible to order by several fields""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(5, users=[user]) + + for parameter in [ + "created_at", + "-created_at", + "is_favorite", + "-is_favorite", + "title", + "-title", + "updated_at", + "-updated_at", + ]: + is_descending = parameter.startswith("-") + field = parameter.lstrip("-") + querystring = f"?ordering={parameter}" + + response = client.get(f"/api/v1.0/documents/{querystring:s}") + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + # Check that results are sorted by the field in querystring as expected + compare = operator.ge if is_descending else operator.le + for i in range(4): + assert compare(results[i][field], results[i + 1][field]) + + +# Filters: unknown field + + +def test_api_documents_list_filter_unknown_field(): + """ + Trying to filter by an unknown field should raise a 400 error. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory() + expected_ids = { + str(document.id) + for document in factories.DocumentFactory.create_batch(2, users=[user]) + } + + response = client.get("/api/v1.0/documents/?unknown=true") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + assert {result["id"] for result in results} == expected_ids + + +# Filters: is_creator_me + + +def test_api_documents_list_filter_is_creator_me_true(): + """ + Authenticated users should be able to filter documents they created. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user], creator=user) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_creator_me=true") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 3 + + # Ensure all results are created by the current user + for result in results: + assert result["creator"] == str(user.id) + + +def test_api_documents_list_filter_is_creator_me_false(): + """ + Authenticated users should be able to filter documents created by others. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user], creator=user) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_creator_me=false") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + + # Ensure all results are created by other users + for result in results: + assert result["creator"] != str(user.id) + + +def test_api_documents_list_filter_is_creator_me_invalid(): + """Filtering with an invalid `is_creator_me` value should do nothing.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user], creator=user) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_creator_me=invalid") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + +# Filters: is_favorite + + +def test_api_documents_list_filter_is_favorite_true(): + """ + Authenticated users should be able to filter documents they marked as favorite. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user]) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_favorite=true") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 3 + + # Ensure all results are marked as favorite by the current user + for result in results: + assert result["is_favorite"] is True + + +def test_api_documents_list_filter_is_favorite_false(): + """ + Authenticated users should be able to filter documents they didn't mark as favorite. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user]) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_favorite=false") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + + # Ensure all results are not marked as favorite by the current user + for result in results: + assert result["is_favorite"] is False + + +def test_api_documents_list_filter_is_favorite_invalid(): + """Filtering with an invalid `is_favorite` value should do nothing.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user]) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_favorite=invalid") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + +# Filters: title + + +@pytest.mark.parametrize( + "query,nb_results", + [ + ("Project Alpha", 1), # Exact match + ("project", 2), # Partial match (case-insensitive) + ("Guide", 1), # Word match within a title + ("Special", 0), # No match (nonexistent keyword) + ("2024", 2), # Match by numeric keyword + ("", 5), # Empty string + ], +) +def test_api_documents_list_filter_title(query, nb_results): + """Authenticated users should be able to search documents by their title.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create documents with predefined titles + titles = [ + "Project Alpha Documentation", + "Project Beta Overview", + "User Guide", + "Financial Report 2024", + "Annual Review 2024", + ] + for title in titles: + parent = factories.DocumentFactory() if random.choice([True, False]) else None + factories.DocumentFactory(title=title, users=[user], parent=parent) + + # Perform the search query + response = client.get(f"/api/v1.0/documents/?title={query:s}") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == nb_results + + # Ensure all results contain the query in their title + for result in results: + assert query.lower().strip() in result["title"].lower() diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_media_auth.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_media_auth.py new file mode 100644 index 00000000..37f88daa --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_media_auth.py @@ -0,0 +1,403 @@ +""" +Test media-auth authorization API endpoint in docs core app. +""" + +from io import BytesIO +from urllib.parse import urlparse +from uuid import uuid4 + +from django.conf import settings +from django.core.files.storage import default_storage +from django.utils import timezone + +import pytest +import requests +from rest_framework.test import APIClient + +from core import factories, models +from core.enums import DocumentAttachmentStatus +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_documents_media_auth_unkown_document(): + """ + Trying to download a media related to a document ID that does not exist + should not have the side effect to create it (no regression test). + """ + original_url = f"http://localhost/media/{uuid4()!s}/attachments/{uuid4()!s}.jpg" + + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 + assert models.Document.objects.exists() is False + + +def test_api_documents_media_auth_anonymous_public(): + """Anonymous users should be able to retrieve attachments linked to a public document""" + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + + factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) + + original_url = f"http://localhost/media/{key:s}" + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" + + +def test_api_documents_media_auth_extensions(): + """Files with extensions of any format should work.""" + extensions = [ + "c", + "go", + "gif", + "mp4", + "woff2", + "appimage", + ] + document_id = uuid4() + keys = [] + for ext in extensions: + filename = f"{uuid4()!s}.{ext:s}" + key = f"{document_id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + keys.append(key) + + factories.DocumentFactory(link_reach="public", attachments=keys) + + for key in keys: + original_url = f"http://localhost/media/{key:s}" + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 200 + + +@pytest.mark.parametrize("reach", ["authenticated", "restricted"]) +def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach): + """ + Anonymous users should not be allowed to retrieve attachments linked to a document + with link reach set to authenticated or restricted. + """ + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + media_url = f"http://localhost/media/{document_id!s}/attachments/{filename:s}" + + factories.DocumentFactory(id=document_id, link_reach=reach) + + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) + + assert response.status_code == 403 + assert "Authorization" not in response + + +def test_api_documents_media_auth_anonymous_attachments(): + """ + Declaring a media key as original attachment on a document to which + a user has access should give them access to the attachment file + regardless of their access rights on the original document. + """ + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + media_url = f"http://localhost/media/{key:s}" + + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + + factories.DocumentFactory(id=document_id, link_reach="restricted") + + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) + assert response.status_code == 403 + + # Let's now add a document to which the anonymous user has access and + # pointing to the attachment + parent = factories.DocumentFactory(link_reach="public") + factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key]) + + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_media_auth_authenticated_public_or_authenticated(reach): + """ + Authenticated users who are not related to a document should be able to retrieve + attachments related to a document with public or authenticated link reach. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + media_url = f"http://localhost/media/{key:s}" + + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + + factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key]) + + response = client.get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" + + +def test_api_documents_media_auth_authenticated_restricted(): + """ + Authenticated users who are not related to a document should not be allowed to + retrieve attachments linked to a document that is restricted. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + media_url = f"http://localhost/media/{key:s}" + + factories.DocumentFactory( + id=document_id, link_reach="restricted", attachments=[key] + ) + + response = client.get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) + + assert response.status_code == 403 + assert "Authorization" not in response + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_media_auth_related(via, mock_user_teams): + """ + Users who have a specific access to a document, whatever the role, should be able to + retrieve related attachments. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + media_url = f"http://localhost/media/{key:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + + document = factories.DocumentFactory( + id=document_id, link_reach="restricted", attachments=[key] + ) + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory(document=document, team="lasuite") + + response = client.get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" + + +def test_api_documents_media_auth_not_ready_status(): + """Attachments with status not ready should not be accessible""" + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.PROCESSING}, + ) + + factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) + + original_url = f"http://localhost/media/{key:s}" + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 + + +def test_api_documents_media_auth_missing_status_metadata(): + """Attachments without status metadata should be considered as ready""" + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + ) + + factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) + + original_url = f"http://localhost/media/{key:s}" + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_media_check.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_media_check.py new file mode 100644 index 00000000..81cec061 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_media_check.py @@ -0,0 +1,244 @@ +"""Test the "media_check" endpoint.""" + +from io import BytesIO +from uuid import uuid4 + +from django.core.files.storage import default_storage + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.enums import DocumentAttachmentStatus +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_documents_media_check_unknown_document(): + """ + The "media_check" endpoint should return a 404 error if the document does not exist. + """ + client = APIClient() + response = client.get(f"/api/v1.0/documents/{uuid4()!s}media-check/") + assert response.status_code == 404 + + +def test_api_documents_media_check_missing_key(): + """ + The "media_check" endpoint should return a 404 error if the key is missing. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user=user) + + document = factories.DocumentFactory(users=[user]) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/media-check/") + assert response.status_code == 400 + assert response.json() == {"detail": "Missing 'key' query parameter"} + + +def test_api_documents_media_check_key_parameter_not_related_to_document(): + """ + The "media_check" endpoint should return a 404 error if the key is not related to the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user=user) + + document = factories.DocumentFactory(users=[user]) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/media-check/", + {"key": f"{document.id!s}/attachments/unknown.jpg"}, + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Attachment missing"} + + +def test_api_documents_media_check_anonymous_public_document(): + """ + The "media_check" endpoint should return a 200 status code if the document is public. + """ + document = factories.DocumentFactory(link_reach="public") + + filename = f"{uuid4()!s}.jpg" + key = f"{document.id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.PROCESSING}, + ) + document.attachments = [key] + document.save(update_fields=["attachments"]) + + client = APIClient() + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key} + ) + assert response.status_code == 200 + assert response.json() == {"status": DocumentAttachmentStatus.PROCESSING} + + +def test_api_documents_media_check_anonymous_public_document_ready(): + """ + The "media_check" endpoint should return a 200 status code if the document is public. + """ + document = factories.DocumentFactory(link_reach="public") + + filename = f"{uuid4()!s}.jpg" + key = f"{document.id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + document.attachments = [key] + document.save(update_fields=["attachments"]) + + client = APIClient() + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key} + ) + assert response.status_code == 200 + assert response.json() == { + "status": DocumentAttachmentStatus.READY, + "file": f"/media/{key:s}", + } + + +@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"]) +def test_api_documents_media_check_anonymous_non_public_document(link_reach): + """ + The "media_check" endpoint should return a 403 error if the document is not public. + """ + document = factories.DocumentFactory(link_reach=link_reach) + + client = APIClient() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/media-check/") + assert response.status_code == 401 + + +def test_api_documents_media_check_connected_document(): + """ + The "media_check" endpoint should return a 200 status code for a user connected + checking for a document with link_reach authenticated. + """ + document = factories.DocumentFactory(link_reach="authenticated") + + filename = f"{uuid4()!s}.jpg" + key = f"{document.id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + document.attachments = [key] + document.save(update_fields=["attachments"]) + + user = factories.UserFactory() + client = APIClient() + client.force_login(user=user) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key} + ) + assert response.status_code == 200 + assert response.json() == { + "status": DocumentAttachmentStatus.READY, + "file": f"/media/{key:s}", + } + + +def test_api_documents_media_check_connected_document_media_not_related(): + """ + The "media_check" endpoint should return a 404 error if the key is not related to the document. + """ + document = factories.DocumentFactory(link_reach="authenticated") + + filename = f"{uuid4()!s}.jpg" + key = f"{document.id!s}/attachments/{filename:s}" + + user = factories.UserFactory() + client = APIClient() + client.force_login(user=user) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key} + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Attachment missing"} + + +def test_api_documents_media_check_media_missing_on_storage(): + """ + The "media_check" endpoint should return a 404 error if the media is missing on storage. + """ + document = factories.DocumentFactory(link_reach="authenticated") + + filename = f"{uuid4()!s}.jpg" + key = f"{document.id!s}/attachments/{filename:s}" + + document.attachments = [key] + document.save(update_fields=["attachments"]) + + user = factories.UserFactory() + client = APIClient() + client.force_login(user=user) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key} + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Media not found"} + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_media_check_restricted_document(via, mock_user_teams): + """ + The "media_check" endpoint should return a 200 status code if the document is restricted and + the user has access to it. + """ + document = factories.DocumentFactory(link_reach="restricted") + filename = f"{uuid4()!s}.jpg" + key = f"{document.id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + document.attachments = [key] + document.save(update_fields=["attachments"]) + + user = factories.UserFactory() + client = APIClient() + client.force_login(user=user) + + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory(document=document, team="lasuite") + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key} + ) + assert response.status_code == 200 + assert response.json() == { + "status": DocumentAttachmentStatus.READY, + "file": f"/media/{key:s}", + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_move.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_move.py new file mode 100644 index 00000000..a0dd8350 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_move.py @@ -0,0 +1,339 @@ +""" +Test moving documents within the document tree via an detail action API endpoint. +""" + +import random +from uuid import uuid4 + +from django.utils import timezone + +import pytest +from rest_framework.test import APIClient + +from core import enums, factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_documents_move_anonymous_user(): + """Anonymous users should not be able to move documents.""" + document = factories.DocumentFactory() + target = factories.DocumentFactory() + + response = APIClient().post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id)}, + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("role", [None, "reader", "editor"]) +def test_api_documents_move_authenticated_document_no_permission(role): + """ + Authenticated users should not be able to move documents with insufficient + permissions on the origin document. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + target = factories.UserDocumentAccessFactory(user=user, role="owner").document + + if role: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id)}, + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_move_invalid_target_string(): + """Test for moving a document to an invalid target as a random string.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.UserDocumentAccessFactory(user=user, role="owner").document + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": "non-existent-id"}, + ) + + assert response.status_code == 400 + assert response.json() == {"target_document_id": ["Must be a valid UUID."]} + + +def test_api_documents_move_invalid_target_uuid(): + """Test for moving a document to an invalid target that looks like a UUID.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.UserDocumentAccessFactory(user=user, role="owner").document + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(uuid4())}, + ) + + assert response.status_code == 400 + assert response.json() == { + "target_document_id": "Target parent document does not exist." + } + + +def test_api_documents_move_invalid_position(): + """Test moving a document to an invalid position.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.UserDocumentAccessFactory(user=user, role="owner").document + target = factories.UserDocumentAccessFactory(user=user, role="owner").document + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={ + "target_document_id": str(target.id), + "position": "invalid-position", + }, + ) + + assert response.status_code == 400 + assert response.json() == { + "position": ['"invalid-position" is not a valid choice.'] + } + + +@pytest.mark.parametrize("position", enums.MoveNodePositionChoices.values) +@pytest.mark.parametrize("target_parent_role", models.RoleChoices.values) +@pytest.mark.parametrize("target_role", models.RoleChoices.values) +def test_api_documents_move_authenticated_target_roles_mocked( + target_role, target_parent_role, position +): + """ + Authenticated users with insufficient permissions on the target document (or its + parent depending on the position chosen), should not be allowed to move documents. + """ + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + power_roles = ["administrator", "owner"] + + document = factories.DocumentFactory(users=[(user, random.choice(power_roles))]) + children = factories.DocumentFactory.create_batch(3, parent=document) + + target_parent = factories.DocumentFactory(users=[(user, target_parent_role)]) + sibling1, target, sibling2 = factories.DocumentFactory.create_batch( + 3, parent=target_parent + ) + models.DocumentAccess.objects.create(document=target, user=user, role=target_role) + target_children = factories.DocumentFactory.create_batch(2, parent=target) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id), "position": position}, + ) + + document.refresh_from_db() + + if ( + position in ["first-child", "last-child"] + and (target_role in power_roles or target_parent_role in power_roles) + ) or ( + position in ["first-sibling", "last-sibling", "left", "right"] + and target_parent_role in power_roles + ): + assert response.status_code == 200 + assert response.json() == {"message": "Document moved successfully."} + + match position: + case "first-child": + assert list(target.get_children()) == [document, *target_children] + case "last-child": + assert list(target.get_children()) == [*target_children, document] + case "first-sibling": + assert list(target.get_siblings()) == [ + document, + sibling1, + target, + sibling2, + ] + case "last-sibling": + assert list(target.get_siblings()) == [ + sibling1, + target, + sibling2, + document, + ] + case "left": + assert list(target.get_siblings()) == [ + sibling1, + document, + target, + sibling2, + ] + case "right": + assert list(target.get_siblings()) == [ + sibling1, + target, + document, + sibling2, + ] + case _: + raise ValueError(f"Invalid position: {position}") + + # Verify that the document's children have also been moved + assert list(document.get_children()) == children + else: + assert response.status_code == 400 + assert ( + "You do not have permission to move documents" + in response.json()["target_document_id"] + ) + assert document.is_root() is True + + +def test_api_documents_move_authenticated_deleted_document(): + """ + It should not be possible to move a deleted document or its descendants, even + for an owner. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory( + users=[(user, "owner")], deleted_at=timezone.now() + ) + child = factories.DocumentFactory(parent=document, users=[(user, "owner")]) + + target = factories.DocumentFactory(users=[(user, "owner")]) + + # Try moving the deleted document + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id)}, + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify that the document has not moved + document.refresh_from_db() + assert document.is_root() is True + + # Try moving the child of the deleted document + response = client.post( + f"/api/v1.0/documents/{child.id!s}/move/", + data={"target_document_id": str(target.id)}, + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify that the child has not moved + child.refresh_from_db() + assert child.is_child_of(document) is True + + +@pytest.mark.parametrize( + "position", + enums.MoveNodePositionChoices.values, +) +def test_api_documents_move_authenticated_deleted_target_as_child(position): + """ + It should not be possible to move a document as a child of a deleted target + even for a owner. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[(user, "owner")]) + + target = factories.DocumentFactory( + users=[(user, "owner")], deleted_at=timezone.now() + ) + child = factories.DocumentFactory(parent=target, users=[(user, "owner")]) + + # Try moving the document to the deleted target + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id), "position": position}, + ) + + assert response.status_code == 400 + assert response.json() == { + "target_document_id": "Target parent document does not exist." + } + + # Verify that the document has not moved + document.refresh_from_db() + assert document.is_root() is True + + # Try moving the document to the child of the deleted target + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(child.id), "position": position}, + ) + assert response.status_code == 400 + assert response.json() == { + "target_document_id": "Target parent document does not exist." + } + + # Verify that the document has not moved + document.refresh_from_db() + assert document.is_root() is True + + +@pytest.mark.parametrize( + "position", + ["first-sibling", "last-sibling", "left", "right"], +) +def test_api_documents_move_authenticated_deleted_target_as_sibling(position): + """ + It should not be possible to move a document as a sibling of a deleted target document + if the user has no rights on its parent. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[(user, "owner")]) + + target_parent = factories.DocumentFactory( + users=[(user, "owner")], deleted_at=timezone.now() + ) + target = factories.DocumentFactory(users=[(user, "owner")], parent=target_parent) + + # Try moving the document as a sibling of the target + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id), "position": position}, + ) + + assert response.status_code == 400 + assert response.json() == { + "target_document_id": "Target parent document does not exist." + } + + # Verify that the document has not moved + document.refresh_from_db() + assert document.is_root() is True diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_restore.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_restore.py new file mode 100644 index 00000000..5ae64aec --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_restore.py @@ -0,0 +1,126 @@ +""" +Test restoring documents after a soft delete via the detail action API endpoint. +""" + +from datetime import timedelta + +from django.utils import timezone + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_restore_anonymous_user(): + """Anonymous users should not be able to restore deleted documents.""" + now = timezone.now() - timedelta(days=15) + document = factories.DocumentFactory(deleted_at=now) + + response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/restore/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + document.refresh_from_db() + assert document.deleted_at == now + assert document.ancestors_deleted_at == now + + +@pytest.mark.parametrize("role", [None, "reader", "editor", "administrator"]) +def test_api_documents_restore_authenticated_no_permission(role): + """ + Authenticated users who are not owners of a deleted document should + not be allowed to restore it. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + now = timezone.now() - timedelta(days=15) + document = factories.DocumentFactory( + deleted_at=now, link_reach="public", link_role="editor" + ) + if role: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + + response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + document.refresh_from_db() + assert document.deleted_at == now + assert document.ancestors_deleted_at == now + + +def test_api_documents_restore_authenticated_owner_success(): + """The owner of a deleted document should be able to restore it.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + now = timezone.now() - timedelta(days=15) + document = factories.DocumentFactory(deleted_at=now) + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + + response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/") + + assert response.status_code == 200 + assert response.json() == {"detail": "Document has been successfully restored."} + + document.refresh_from_db() + assert document.deleted_at is None + assert document.ancestors_deleted_at is None + + +def test_api_documents_restore_authenticated_owner_ancestor_deleted(): + """ + The restored document should still be marked as deleted if one of its + ancestors is soft deleted as well. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory() + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + + document.soft_delete() + document_deleted_at = document.deleted_at + assert document_deleted_at is not None + + grand_parent.soft_delete() + grand_parent_deleted_at = grand_parent.deleted_at + assert grand_parent_deleted_at is not None + + response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/") + + assert response.status_code == 200 + assert response.json() == {"detail": "Document has been successfully restored."} + + document.refresh_from_db() + assert document.deleted_at is None + # document is still marked as deleted + assert document.ancestors_deleted_at == grand_parent_deleted_at + assert grand_parent_deleted_at > document_deleted_at + + +def test_api_documents_restore_authenticated_owner_expired(): + """It should not be possible to restore a document beyond the allowed time limit.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + now = timezone.now() - timedelta(days=40) + document = factories.DocumentFactory(deleted_at=now) + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + + response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_retrieve.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_retrieve.py new file mode 100644 index 00000000..91e6ca0e --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -0,0 +1,985 @@ +""" +Tests for Documents API endpoint in impress's core app: retrieve +""" + +import random +from datetime import timedelta +from unittest import mock + +from django.utils import timezone + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_documents_retrieve_anonymous_public_standalone(): + """Anonymous users should be allowed to retrieve public documents.""" + document = factories.DocumentFactory(link_reach="public") + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(document.id), + "abilities": { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": document.link_role == "editor", + "children_create": False, + "children_list": True, + "collaboration_auth": True, + "cors_proxy": True, + "descendants": True, + "destroy": False, + "duplicate": True, + # Anonymous user can't favorite a document even with read access + "favorite": False, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": document.link_role == "editor", + "restore": False, + "retrieve": True, + "tree": True, + "update": document.link_role == "editor", + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + }, + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 1, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": "public", + "link_role": document.link_role, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +def test_api_documents_retrieve_anonymous_public_parent(): + """Anonymous users should be allowed to retrieve a document who has a public ancestor.""" + grand_parent = factories.DocumentFactory(link_reach="public") + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + links = document.get_ancestors().values("link_reach", "link_role") + assert response.json() == { + "id": str(document.id), + "abilities": { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": grand_parent.link_role == "editor", + "children_create": False, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + # Anonymous user can't favorite a document even with read access + "favorite": False, + "invite_owner": False, + "link_configuration": False, + "link_select_options": models.LinkReachChoices.get_select_options(links), + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": grand_parent.link_role == "editor", + "restore": False, + "retrieve": True, + "tree": True, + "update": grand_parent.link_role == "editor", + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + }, + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 3, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +def test_api_documents_retrieve_anonymous_public_child(): + """ + Anonymous users having access to a document should not gain access to a parent document. + """ + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]) + ) + factories.DocumentFactory(link_reach="public", parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_retrieve_anonymous_restricted_or_authenticated(reach): + """Anonymous users should not be able to retrieve a document that is not public.""" + document = factories.DocumentFactory(link_reach=reach) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(reach): + """ + Authenticated users should be able to retrieve a public/authenticated document to + which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(document.id), + "abilities": { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": document.link_role == "editor", + "ai_translate": document.link_role == "editor", + "attachment_upload": document.link_role == "editor", + "children_create": document.link_role == "editor", + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": document.link_role == "editor", + "restore": False, + "retrieve": True, + "tree": True, + "update": document.link_role == "editor", + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + }, + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 1, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": reach, + "link_role": document.link_role, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + assert ( + models.LinkTrace.objects.filter(document=document, user=user).exists() is True + ) + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(reach): + """ + Authenticated users should be allowed to retrieve a document who has a public or + authenticated ancestor. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach=reach) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + links = document.get_ancestors().values("link_reach", "link_role") + assert response.json() == { + "id": str(document.id), + "abilities": { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": grand_parent.link_role == "editor", + "ai_translate": grand_parent.link_role == "editor", + "attachment_upload": grand_parent.link_role == "editor", + "children_create": grand_parent.link_role == "editor", + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": False, + "link_select_options": models.LinkReachChoices.get_select_options(links), + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": grand_parent.link_role == "editor", + "restore": False, + "retrieve": True, + "tree": True, + "update": grand_parent.link_role == "editor", + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + }, + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 3, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_retrieve_authenticated_public_or_authenticated_child(reach): + """ + Authenticated users having access to a document should not gain access to a parent document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + factories.DocumentFactory(link_reach=reach, parent=document) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_retrieve_authenticated_trace_twice(reach): + """ + Accessing a document several times should not raise any error even though the + trace already exists for this document and user. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + assert ( + models.LinkTrace.objects.filter(document=document, user=user).exists() is False + ) + + client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert ( + models.LinkTrace.objects.filter(document=document, user=user).exists() is True + ) + + # A second visit should not raise any error + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + + +def test_api_documents_retrieve_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve a document that is restricted and + to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_retrieve_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve a document to which they + are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = factories.UserDocumentAccessFactory(document=document, user=user) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(document.id), + "abilities": document.get_abilities(user), + "content": document.content, + "creator": str(document.creator.id), + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "depth": 1, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +def test_api_documents_retrieve_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve a document if they are related + to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach="restricted") + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + + access = factories.UserDocumentAccessFactory(document=grand_parent, user=user) + factories.UserDocumentAccessFactory(document=grand_parent) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 200 + links = document.get_ancestors().values("link_reach", "link_role") + assert response.json() == { + "id": str(document.id), + "abilities": { + "accesses_manage": access.role in ["administrator", "owner"], + "accesses_view": True, + "ai_transform": access.role != "reader", + "ai_translate": access.role != "reader", + "attachment_upload": access.role != "reader", + "children_create": access.role != "reader", + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": access.role == "owner", + "duplicate": True, + "favorite": True, + "invite_owner": access.role == "owner", + "link_configuration": access.role in ["administrator", "owner"], + "link_select_options": models.LinkReachChoices.get_select_options(links), + "media_auth": True, + "media_check": True, + "move": access.role in ["administrator", "owner"], + "partial_update": access.role != "reader", + "restore": access.role == "owner", + "retrieve": True, + "tree": True, + "update": access.role != "reader", + "versions_destroy": access.role in ["administrator", "owner"], + "versions_list": True, + "versions_retrieve": True, + }, + "content": document.content, + "creator": str(document.creator.id), + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "depth": 3, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": "restricted", + "link_role": document.link_role, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +def test_api_documents_retrieve_authenticated_related_nb_accesses(): + """Validate computation of number of accesses.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach="restricted") + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + + factories.UserDocumentAccessFactory(document=grand_parent, user=user) + factories.UserDocumentAccessFactory(document=parent) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 200 + assert response.json()["nb_accesses_ancestors"] == 3 + assert response.json()["nb_accesses_direct"] == 1 + + factories.UserDocumentAccessFactory(document=grand_parent) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 200 + assert response.json()["nb_accesses_ancestors"] == 4 + assert response.json()["nb_accesses_direct"] == 1 + + +def test_api_documents_retrieve_authenticated_related_child(): + """ + Authenticated users should not be allowed to retrieve a document as a result of being + related to one of its children. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child = factories.DocumentFactory(parent=document) + + factories.UserDocumentAccessFactory(document=child, user=user) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams): + """ + Authenticated users should not be able to retrieve a restricted document related to + teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + + factories.TeamDocumentAccessFactory( + document=document, team="readers", role="reader" + ) + factories.TeamDocumentAccessFactory( + document=document, team="editors", role="editor" + ) + factories.TeamDocumentAccessFactory( + document=document, team="administrators", role="administrator" + ) + factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner") + factories.TeamDocumentAccessFactory(document=document) + factories.TeamDocumentAccessFactory() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize( + "teams,roles", + [ + [["readers"], ["reader"]], + [["unknown", "readers"], ["reader"]], + [["editors"], ["editor"]], + [["unknown", "editors"], ["editor"]], + ], +) +def test_api_documents_retrieve_authenticated_related_team_members( + teams, roles, mock_user_teams +): + """ + Authenticated users should be allowed to retrieve a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = teams + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + factories.TeamDocumentAccessFactory( + document=document, team="readers", role="reader" + ) + factories.TeamDocumentAccessFactory( + document=document, team="editors", role="editor" + ) + factories.TeamDocumentAccessFactory( + document=document, team="administrators", role="administrator" + ) + factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner") + factories.TeamDocumentAccessFactory(document=document) + factories.TeamDocumentAccessFactory() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "id": str(document.id), + "abilities": document.get_abilities(user), + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 1, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": "restricted", + "link_role": document.link_role, + "nb_accesses_ancestors": 5, + "nb_accesses_direct": 5, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": roles, + } + + +@pytest.mark.parametrize( + "teams,roles", + [ + [["administrators"], ["administrator"]], + [["editors", "administrators"], ["administrator", "editor"]], + [["unknown", "administrators"], ["administrator"]], + ], +) +def test_api_documents_retrieve_authenticated_related_team_administrators( + teams, roles, mock_user_teams +): + """ + Authenticated users should be allowed to retrieve a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = teams + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + + factories.TeamDocumentAccessFactory( + document=document, team="readers", role="reader" + ) + factories.TeamDocumentAccessFactory( + document=document, team="editors", role="editor" + ) + factories.TeamDocumentAccessFactory( + document=document, team="administrators", role="administrator" + ) + factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner") + factories.TeamDocumentAccessFactory(document=document) + factories.TeamDocumentAccessFactory() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "id": str(document.id), + "abilities": document.get_abilities(user), + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 1, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": "restricted", + "link_role": document.link_role, + "nb_accesses_ancestors": 5, + "nb_accesses_direct": 5, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": roles, + } + + +@pytest.mark.parametrize( + "teams,roles", + [ + [["owners"], ["owner"]], + [["owners", "administrators"], ["owner", "administrator"]], + [["members", "administrators", "owners"], ["owner", "administrator"]], + [["unknown", "owners"], ["owner"]], + ], +) +def test_api_documents_retrieve_authenticated_related_team_owners( + teams, roles, mock_user_teams +): + """ + Authenticated users should be allowed to retrieve a restricted document to which + they are related via a team whatever the role. + """ + mock_user_teams.return_value = teams + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + + factories.TeamDocumentAccessFactory( + document=document, team="readers", role="reader" + ) + factories.TeamDocumentAccessFactory( + document=document, team="editors", role="editor" + ) + factories.TeamDocumentAccessFactory( + document=document, team="administrators", role="administrator" + ) + factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner") + factories.TeamDocumentAccessFactory(document=document) + factories.TeamDocumentAccessFactory() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "id": str(document.id), + "abilities": document.get_abilities(user), + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 1, + "excerpt": document.excerpt, + "is_favorite": False, + "link_reach": "restricted", + "link_role": document.link_role, + "nb_accesses_ancestors": 5, + "nb_accesses_direct": 5, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": roles, + } + + +def test_api_documents_retrieve_user_roles(django_assert_max_num_queries): + """ + Roles should be annotated on querysets taking into account all documents ancestors. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory( + users=factories.UserFactory.create_batch(2) + ) + parent = factories.DocumentFactory( + parent=grand_parent, users=factories.UserFactory.create_batch(2) + ) + document = factories.DocumentFactory( + parent=parent, users=factories.UserFactory.create_batch(2) + ) + + accesses = ( + factories.UserDocumentAccessFactory(document=grand_parent, user=user), + factories.UserDocumentAccessFactory(document=parent, user=user), + factories.UserDocumentAccessFactory(document=document, user=user), + ) + expected_roles = {access.role for access in accesses} + + with django_assert_max_num_queries(14): + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + + user_roles = response.json()["user_roles"] + assert set(user_roles) == expected_roles + + +def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries): + """If the link traced already exists, the number of queries should be minimal.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[user], link_traces=[user]) + + with django_assert_num_queries(5): + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + with django_assert_num_queries(3): + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + + assert response.json()["id"] == str(document.id) + + +# Soft/permanent delete + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_documents_retrieve_soft_deleted_anonymous(reach, depth): + """ + A soft/permanently deleted public document should not be accessible via its + detail endpoint for anonymous users, and should return a 404. + """ + documents = [] + for i in range(depth): + documents.append( + factories.DocumentFactory(link_reach=reach) + if i == 0 + else factories.DocumentFactory(parent=documents[-1]) + ) + assert models.Document.objects.count() == depth + + response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/") + + assert response.status_code == 200 if reach == "public" else 401 + + # Delete any one of the documents... + deleted_document = random.choice(documents) + deleted_document.soft_delete() + + response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + fourty_days_ago = timezone.now() - timedelta(days=40) + deleted_document.deleted_at = fourty_days_ago + deleted_document.ancestors_deleted_at = fourty_days_ago + deleted_document.save() + + response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_documents_retrieve_soft_deleted_authenticated(reach, depth): + """ + A soft/permanently deleted document should not be accessible via its detail endpoint for + authenticated users not related to the document. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + documents = [] + for i in range(depth): + documents.append( + factories.DocumentFactory(link_reach=reach) + if i == 0 + else factories.DocumentFactory(parent=documents[-1]) + ) + assert models.Document.objects.count() == depth + + response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/") + + assert response.status_code == 200 if reach in ["public", "authenticated"] else 403 + + # Delete any one of the documents... + deleted_document = random.choice(documents) + deleted_document.soft_delete() + + response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + fourty_days_ago = timezone.now() - timedelta(days=40) + deleted_document.deleted_at = fourty_days_ago + deleted_document.ancestors_deleted_at = fourty_days_ago + deleted_document.save() + + response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("role", models.RoleChoices.values) +def test_api_documents_retrieve_soft_deleted_related(role, depth): + """ + A soft deleted document should only be accessible via its detail endpoint by + users with specific "owner" access rights. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + documents = [] + for i in range(depth): + documents.append( + factories.UserDocumentAccessFactory(role=role, user=user).document + if i == 0 + else factories.DocumentFactory(parent=documents[-1]) + ) + assert models.Document.objects.count() == depth + document = documents[-1] + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + + # Delete any one of the documents + deleted_document = random.choice(documents) + deleted_document.soft_delete() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + if role == "owner": + assert response.status_code == 200 + assert response.json()["id"] == str(document.id) + else: + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("role", models.RoleChoices.values) +def test_api_documents_retrieve_permanently_deleted_related(role, depth): + """ + A permanently deleted document should not be accessible via its detail endpoint for + authenticated users with specific access rights whatever their role. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + documents = [] + for i in range(depth): + documents.append( + factories.UserDocumentAccessFactory(role=role, user=user).document + if i == 0 + else factories.DocumentFactory(parent=documents[-1]) + ) + assert models.Document.objects.count() == depth + document = documents[-1] + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + + # Delete any one of the documents + deleted_document = random.choice(documents) + fourty_days_ago = timezone.now() - timedelta(days=40) + with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago): + deleted_document.soft_delete() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_trashbin.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_trashbin.py new file mode 100644 index 00000000..4e4eb276 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -0,0 +1,287 @@ +""" +Tests for Documents API endpoint in impress's core app: list +""" + +from datetime import timedelta +from unittest import mock + +from django.utils import timezone + +import pytest +from faker import Faker +from rest_framework.pagination import PageNumberPagination +from rest_framework.test import APIClient + +from core import factories, models + +fake = Faker() +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize("role", models.LinkRoleChoices.values) +@pytest.mark.parametrize("reach", models.LinkReachChoices.values) +def test_api_documents_trashbin_anonymous(reach, role): + """ + Anonymous users should not be allowed to list documents from the trashbin + whatever the link reach and link role + """ + factories.DocumentFactory( + link_reach=reach, link_role=role, deleted_at=timezone.now() + ) + + response = APIClient().get("/api/v1.0/documents/trashbin/") + + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +def test_api_documents_trashbin_format(): + """Validate the format of documents as returned by the trashbin view.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + other_users = factories.UserFactory.create_batch(3) + document = factories.DocumentFactory( + deleted_at=timezone.now(), + users=factories.UserFactory.create_batch(2), + favorited_by=[user, *other_users], + link_traces=other_users, + ) + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + + response = client.get("/api/v1.0/documents/trashbin/") + + assert response.status_code == 200 + + content = response.json() + results = content.pop("results") + assert content == { + "count": 1, + "next": None, + "previous": None, + } + assert len(results) == 1 + assert results[0] == { + "id": str(document.id), + "abilities": { + "accesses_manage": True, + "accesses_view": True, + "ai_transform": True, + "ai_translate": True, + "attachment_upload": True, + "children_create": True, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": True, + "duplicate": True, + "favorite": True, + "invite_owner": True, + "link_configuration": True, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": False, # Can't move a deleted document + "partial_update": True, + "restore": True, + "retrieve": True, + "tree": True, + "update": True, + "versions_destroy": True, + "versions_list": True, + "versions_retrieve": True, + }, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 1, + "excerpt": document.excerpt, + "link_reach": document.link_reach, + "link_role": document.link_role, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 3, + "numchild": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": ["owner"], + } + + +def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries): + """ + The trashbin should only list deleted documents for which the current user is owner. + """ + now = timezone.now() + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document1, document2 = factories.DocumentFactory.create_batch(2, deleted_at=now) + models.DocumentAccess.objects.create(document=document1, user=user, role="owner") + models.DocumentAccess.objects.create(document=document2, user=user, role="owner") + + # Unrelated documents + for reach in models.LinkReachChoices: + for role in models.LinkRoleChoices: + factories.DocumentFactory(link_reach=reach, link_role=role, deleted_at=now) + + # Role other than "owner" + for role in models.RoleChoices.values: + if role == "owner": + continue + document_not_owner = factories.DocumentFactory(deleted_at=now) + models.DocumentAccess.objects.create( + document=document_not_owner, user=user, role=role + ) + + # Nested documents should also get listed + parent = factories.DocumentFactory(parent=document1) + document3 = factories.DocumentFactory(parent=parent, deleted_at=now) + models.DocumentAccess.objects.create(document=parent, user=user, role="owner") + + # Permanently deleted documents should not be listed + fourty_days_ago = timezone.now() - timedelta(days=40) + permanently_deleted_document = factories.DocumentFactory(users=[(user, "owner")]) + with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago): + permanently_deleted_document.soft_delete() + + expected_ids = {str(document1.id), str(document2.id), str(document3.id)} + + with django_assert_num_queries(10): + response = client.get("/api/v1.0/documents/trashbin/") + + with django_assert_num_queries(4): + response = client.get("/api/v1.0/documents/trashbin/") + + assert response.status_code == 200 + results = response.json()["results"] + results_ids = {result["id"] for result in results} + assert len(results) == 3 + assert expected_ids == results_ids + + +def test_api_documents_trashbin_authenticated_via_team( + django_assert_num_queries, mock_user_teams +): + """ + Authenticated users should be able to list trashbin documents they own via a team. + """ + now = timezone.now() + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + mock_user_teams.return_value = ["team1", "team2", "unknown"] + + deleted_document_team1 = factories.DocumentFactory( + teams=[("team1", "owner")], deleted_at=now + ) + factories.DocumentFactory(teams=[("team1", "owner")]) + factories.DocumentFactory(teams=[("team1", "administrator")], deleted_at=now) + factories.DocumentFactory(teams=[("team1", "administrator")]) + deleted_document_team2 = factories.DocumentFactory( + teams=[("team2", "owner")], deleted_at=now + ) + factories.DocumentFactory(teams=[("team2", "owner")]) + factories.DocumentFactory(teams=[("team2", "administrator")], deleted_at=now) + factories.DocumentFactory(teams=[("team2", "administrator")]) + + expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)} + + with django_assert_num_queries(7): + response = client.get("/api/v1.0/documents/trashbin/") + + with django_assert_num_queries(3): + response = client.get("/api/v1.0/documents/trashbin/") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_documents_trashbin_pagination( + _mock_page_size, +): + """Pagination should work as expected.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document_ids = [ + str(document.id) + for document in factories.DocumentFactory.create_batch( + 3, deleted_at=timezone.now() + ) + ] + for document_id in document_ids: + models.DocumentAccess.objects.create( + document_id=document_id, user=user, role="owner" + ) + + # Get page 1 + response = client.get("/api/v1.0/documents/trashbin/") + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/documents/trashbin/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + document_ids.remove(item["id"]) + + # Get page 2 + response = client.get( + "/api/v1.0/documents/trashbin/?page=2", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"] == "http://testserver/api/v1.0/documents/trashbin/" + + assert len(content["results"]) == 1 + document_ids.remove(content["results"][0]["id"]) + assert document_ids == [] + + +def test_api_documents_trashbin_distinct(): + """A document with several related users should only be listed once.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + document = factories.DocumentFactory( + users=[(user, "owner"), other_user], deleted_at=timezone.now() + ) + + response = client.get( + "/api/v1.0/documents/trashbin/", + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 1 + assert content["results"][0]["id"] == str(document.id) diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_tree.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_tree.py new file mode 100644 index 00000000..33fa614b --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_tree.py @@ -0,0 +1,1031 @@ +""" +Tests for Documents API endpoint in impress's core app: retrieve +""" +# pylint: disable=too-many-lines + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_queries): + """Anonymous users should be allowed to retrieve the tree of a public document.""" + parent = factories.DocumentFactory(link_reach="public") + document, sibling1, sibling2 = factories.DocumentFactory.create_batch( + 3, parent=parent + ) + child = factories.DocumentFactory(link_reach="public", parent=document) + + with django_assert_num_queries(14): + APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + with django_assert_num_queries(4): + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": document.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": child.get_abilities(AnonymousUser()), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling1.get_abilities(AnonymousUser()), + "children": [], + "created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling1.creator.id), + "depth": 2, + "excerpt": sibling1.excerpt, + "id": str(sibling1.id), + "is_favorite": False, + "link_reach": sibling1.link_reach, + "link_role": sibling1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling1.path, + "title": sibling1.title, + "updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling2.get_abilities(AnonymousUser()), + "children": [], + "created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling2.creator.id), + "depth": 2, + "excerpt": sibling2.excerpt, + "id": str(sibling2.id), + "is_favorite": False, + "link_reach": sibling2.link_reach, + "link_role": sibling2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling2.path, + "title": sibling2.title, + "updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 3, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +def test_api_documents_tree_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the tree of a document who + has a public ancestor but only up to the highest public ancestor. + """ + great_grand_parent = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]) + ) + grand_parent = factories.DocumentFactory( + link_reach="public", parent=great_grand_parent + ) + factories.DocumentFactory(link_reach="public", parent=great_grand_parent) + factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), + parent=great_grand_parent, + ) + + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + parent_sibling = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + document_sibling = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": document.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": child.get_abilities(AnonymousUser()), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + { + "abilities": document_sibling.get_abilities(AnonymousUser()), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": parent_sibling.get_abilities(AnonymousUser()), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_tree_list_anonymous_restricted_or_authenticated(reach): + """ + Anonymous users should not be able to retrieve the tree of a document that is not public. + """ + parent = factories.DocumentFactory(link_reach=reach) + document = factories.DocumentFactory(parent=parent, link_reach=reach) + factories.DocumentFactory(parent=parent) + factories.DocumentFactory(link_reach="public", parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated( + reach, django_assert_num_queries +): + """ + Authenticated users should be able to retrieve the tree of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach=reach) + document, sibling = factories.DocumentFactory.create_batch(2, parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + with django_assert_num_queries(13): + client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + with django_assert_num_queries(5): + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the tree of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + great_grand_parent = factories.DocumentFactory(link_reach="restricted") + grand_parent = factories.DocumentFactory( + link_reach=reach, parent=great_grand_parent + ) + factories.DocumentFactory( + link_reach=random.choice(["public", "authenticated"]), parent=great_grand_parent + ) + factories.DocumentFactory( + link_reach="restricted", + parent=great_grand_parent, + ) + + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + parent_sibling = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + document_sibling = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory( + link_reach=random.choice(["public", "authenticated"]), parent=document + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(user), + "children": [ + { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + { + "abilities": document_sibling.get_abilities(user), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": parent_sibling.get_abilities(user), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +def test_api_documents_tree_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the tree of a document that is + restricted and to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, _sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + factories.DocumentFactory(link_reach="public", parent=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/tree/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_tree_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the tree of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + access = factories.UserDocumentAccessFactory(document=parent, user=user) + factories.UserDocumentAccessFactory(document=parent) + + document, sibling = factories.DocumentFactory.create_batch(2, parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/tree/", + ) + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +def test_api_documents_tree_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the tree of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + great_grand_parent = factories.DocumentFactory( + link_reach="restricted", link_role="reader" + ) + grand_parent = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=great_grand_parent + ) + access = factories.UserDocumentAccessFactory(document=grand_parent, user=user) + factories.UserDocumentAccessFactory(document=grand_parent) + factories.DocumentFactory(link_reach="restricted", parent=great_grand_parent) + factories.DocumentFactory(link_reach="public", parent=great_grand_parent) + + parent = factories.DocumentFactory( + parent=grand_parent, link_reach="restricted", link_role="reader" + ) + parent_sibling = factories.DocumentFactory( + parent=grand_parent, link_reach="restricted", link_role="reader" + ) + document = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=parent + ) + document_sibling = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=parent + ) + child = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=document + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(user), + "children": [ + { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + { + "abilities": document_sibling.get_abilities(user), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": parent_sibling.get_abilities(user), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +def test_api_documents_tree_list_authenticated_related_team_none(mock_user_teams): + """ + Authenticated users should not be able to retrieve the tree of a restricted document + related to teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, _sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + factories.DocumentFactory(link_reach="public", parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_tree_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the tree of a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = ["myteam"] + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + child = factories.DocumentFactory(link_reach="public", parent=document) + + access = factories.TeamDocumentAccessFactory(document=parent, team="myteam") + factories.TeamDocumentAccessFactory(document=parent, team="another-team") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_update.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_update.py new file mode 100644 index 00000000..1c583bc9 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_update.py @@ -0,0 +1,349 @@ +""" +Tests for Documents API endpoint in impress's core app: update +""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize("via_parent", [True, False]) +@pytest.mark.parametrize( + "reach, role", + [ + ("restricted", "reader"), + ("restricted", "editor"), + ("authenticated", "reader"), + ("authenticated", "editor"), + ("public", "reader"), + ], +) +def test_api_documents_update_anonymous_forbidden(reach, role, via_parent): + """ + Anonymous users should not be allowed to update a document when link + configuration does not allow it. + """ + if via_parent: + grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + else: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = APIClient().put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + assert document_values == old_document_values + + +@pytest.mark.parametrize("via_parent", [True, False]) +@pytest.mark.parametrize( + "reach,role", + [ + ("public", "reader"), + ("authenticated", "reader"), + ("restricted", "reader"), + ("restricted", "editor"), + ], +) +def test_api_documents_update_authenticated_unrelated_forbidden( + reach, role, via_parent +): + """ + Authenticated users should not be allowed to update a document to which + they are not related if the link configuration does not allow it. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + if via_parent: + grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + else: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + old_document_values = serializers.DocumentSerializer(instance=document).data + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + assert document_values == old_document_values + + +@pytest.mark.parametrize("via_parent", [True, False]) +@pytest.mark.parametrize( + "is_authenticated,reach,role", + [ + (False, "public", "editor"), + (True, "public", "editor"), + (True, "authenticated", "editor"), + ], +) +def test_api_documents_update_anonymous_or_authenticated_unrelated( + is_authenticated, reach, role, via_parent +): + """ + Anonymous and authenticated users should be able to update a document to which + they are not related if the link configuration allows it. + """ + client = APIClient() + + if is_authenticated: + user = factories.UserFactory(with_owned_document=True) + client.force_login(user) + else: + user = AnonymousUser() + + if via_parent: + grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + else: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + + old_document_values = serializers.DocumentSerializer(instance=document).data + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + document = models.Document.objects.get(pk=document.pk) + document_values = serializers.DocumentSerializer(instance=document).data + for key, value in document_values.items(): + if key in [ + "id", + "accesses", + "created_at", + "creator", + "depth", + "link_reach", + "link_role", + "numchild", + "path", + ]: + assert value == old_document_values[key] + elif key == "updated_at": + assert value > old_document_values[key] + else: + assert value == new_document_values[key] + + +@pytest.mark.parametrize("via_parent", [True, False]) +@pytest.mark.parametrize("via", VIA) +def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_teams): + """ + Users who are reader of a document should not be allowed to update it. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + if via_parent: + grand_parent = factories.DocumentFactory(link_reach="restricted") + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + access_document = grand_parent + else: + document = factories.DocumentFactory(link_reach="restricted") + access_document = document + + if via == USER: + factories.UserDocumentAccessFactory( + document=access_document, user=user, role="reader" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=access_document, team="lasuite", role="reader" + ) + + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + assert document_values == old_document_values + + +@pytest.mark.parametrize("via_parent", [True, False]) +@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) +@pytest.mark.parametrize("via", VIA) +def test_api_documents_update_authenticated_editor_administrator_or_owner( + via, role, via_parent, mock_user_teams +): + """A user who is editor, administrator or owner of a document should be allowed to update it.""" + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + if via_parent: + grand_parent = factories.DocumentFactory(link_reach="restricted") + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + access_document = grand_parent + else: + document = factories.DocumentFactory(link_reach="restricted") + access_document = document + + if via == USER: + factories.UserDocumentAccessFactory( + document=access_document, user=user, role=role + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=access_document, team="lasuite", role=role + ) + + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + document = models.Document.objects.get(pk=document.pk) + document_values = serializers.DocumentSerializer(instance=document).data + for key, value in document_values.items(): + if key in [ + "id", + "created_at", + "creator", + "depth", + "link_reach", + "link_role", + "nb_accesses_ancestors", + "nb_accesses_direct", + "numchild", + "path", + ]: + assert value == old_document_values[key] + elif key == "updated_at": + assert value > old_document_values[key] + else: + assert value == new_document_values[key] + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams): + """ + Being administrator or owner of a document should not grant authorization to update + another document. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role=random.choice(["administrator", "owner"]) + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, + team="lasuite", + role=random.choice(["administrator", "owner"]), + ) + + other_document = factories.DocumentFactory(title="Old title", link_role="reader") + old_document_values = serializers.DocumentSerializer(instance=other_document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{other_document.id!s}/", + new_document_values, + format="json", + ) + + assert response.status_code == 403 + + other_document.refresh_from_db() + other_document_values = serializers.DocumentSerializer(instance=other_document).data + assert other_document_values == old_document_values + + +def test_api_documents_update_invalid_content(): + """ + Updating a document with a non base64 encoded content should raise a validation error. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[[user, "owner"]]) + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + {"content": "invalid content"}, + format="json", + ) + assert response.status_code == 400 + assert response.json() == {"content": ["Invalid base64 content."]} diff --git a/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py new file mode 100644 index 00000000..10c798f8 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py @@ -0,0 +1,154 @@ +""" +Test extract-attachments on document update in docs core app. +""" + +import base64 +from uuid import uuid4 + +import pycrdt +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def get_ydoc_with_mages(image_keys): + """Return a ydoc from text for testing purposes.""" + ydoc = pycrdt.Doc() + fragment = pycrdt.XmlFragment( + [ + pycrdt.XmlElement("img", {"src": f"http://localhost/media/{key:s}"}) + for key in image_keys + ] + ) + ydoc["document-store"] = fragment + update = ydoc.get_update() + return base64.b64encode(update).decode("utf-8") + + +def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_queries): + """ + When an anonymous user updates a document, the attachment keys extracted from the + updated content should be added to the list of "attachments" to the document if these + attachments are already readable by anonymous users. + """ + image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(4)] + document = factories.DocumentFactory( + content=get_ydoc_with_mages(image_keys[:1]), + attachments=[image_keys[0]], + link_reach="public", + link_role="editor", + ) + + factories.DocumentFactory(attachments=[image_keys[1]], link_reach="public") + factories.DocumentFactory(attachments=[image_keys[2]], link_reach="authenticated") + factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted") + expected_keys = {image_keys[i] for i in [0, 1]} + + with django_assert_num_queries(9): + response = APIClient().put( + f"/api/v1.0/documents/{document.id!s}/", + {"content": get_ydoc_with_mages(image_keys)}, + format="json", + ) + assert response.status_code == 200 + + document.refresh_from_db() + assert set(document.attachments) == expected_keys + + # Check that the db query to check attachments readability for extracted + # keys is not done if the content changes but no new keys are found + with django_assert_num_queries(5): + response = APIClient().put( + f"/api/v1.0/documents/{document.id!s}/", + {"content": get_ydoc_with_mages(image_keys[:2])}, + format="json", + ) + assert response.status_code == 200 + + document.refresh_from_db() + assert len(document.attachments) == 2 + assert set(document.attachments) == expected_keys + + +def test_api_documents_update_new_attachment_keys_authenticated( + django_assert_num_queries, +): + """ + When an authenticated user updates a document, the attachment keys extracted from the + updated content should be added to the list of "attachments" to the document if these + attachments are already readable by the editing user. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(5)] + document = factories.DocumentFactory( + content=get_ydoc_with_mages(image_keys[:1]), + attachments=[image_keys[0]], + users=[(user, "editor")], + ) + + factories.DocumentFactory(attachments=[image_keys[1]], link_reach="public") + factories.DocumentFactory(attachments=[image_keys[2]], link_reach="authenticated") + factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted") + factories.DocumentFactory(attachments=[image_keys[4]], users=[user]) + expected_keys = {image_keys[i] for i in [0, 1, 2, 4]} + + with django_assert_num_queries(10): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + {"content": get_ydoc_with_mages(image_keys)}, + format="json", + ) + assert response.status_code == 200 + + document.refresh_from_db() + assert set(document.attachments) == expected_keys + + # Check that the db query to check attachments readability for extracted + # keys is not done if the content changes but no new keys are found + with django_assert_num_queries(6): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + {"content": get_ydoc_with_mages(image_keys[:2])}, + format="json", + ) + assert response.status_code == 200 + + document.refresh_from_db() + assert len(document.attachments) == 4 + assert set(document.attachments) == expected_keys + + +def test_api_documents_update_new_attachment_keys_duplicate(): + """ + Duplicate keys in the content should not result in duplicates in the document's attachments. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + image_key1 = f"{uuid4()!s}/attachments/{uuid4()!s}.png" + image_key2 = f"{uuid4()!s}/attachments/{uuid4()!s}.png" + document = factories.DocumentFactory( + content=get_ydoc_with_mages([image_key1]), + attachments=[image_key1], + users=[(user, "editor")], + ) + + factories.DocumentFactory(attachments=[image_key2], users=[user]) + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + {"content": get_ydoc_with_mages([image_key1, image_key2, image_key2])}, + format="json", + ) + assert response.status_code == 200 + + document.refresh_from_db() + assert len(document.attachments) == 2 + assert set(document.attachments) == {image_key1, image_key2} diff --git a/submissions/devoteam/docs/src/backend/core/tests/migrations/__init__.py b/submissions/devoteam/docs/src/backend/core/tests/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/tests/migrations/test_migrations_0018_update_blank_title.py b/submissions/devoteam/docs/src/backend/core/tests/migrations/test_migrations_0018_update_blank_title.py new file mode 100644 index 00000000..192103f4 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/migrations/test_migrations_0018_update_blank_title.py @@ -0,0 +1,47 @@ +import pytest + +from core import models + + +@pytest.mark.django_db +def test_update_blank_title_migration(migrator): + """ + Test that the migration fixes the titles of documents that are + "Untitled document", "Unbenanntes Dokument" or "Document sans titre" + """ + old_state = migrator.apply_initial_migration( + ("core", "0017_add_fields_for_soft_delete") + ) + OldDocument = old_state.apps.get_model("core", "Document") + + old_english_doc = OldDocument.objects.create( + title="Untitled document", depth=1, path="0000001" + ) + old_german_doc = OldDocument.objects.create( + title="Unbenanntes Dokument", depth=1, path="0000002" + ) + old_french_doc = OldDocument.objects.create( + title="Document sans titre", depth=1, path="0000003" + ) + old_other_doc = OldDocument.objects.create( + title="My document", depth=1, path="0000004" + ) + + assert old_english_doc.title == "Untitled document" + assert old_german_doc.title == "Unbenanntes Dokument" + assert old_french_doc.title == "Document sans titre" + assert old_other_doc.title == "My document" + + # Apply the migration + new_state = migrator.apply_tested_migration(("core", "0018_update_blank_title")) + NewDocument = new_state.apps.get_model("core", "Document") + + new_english_doc = NewDocument.objects.get(pk=old_english_doc.pk) + new_german_doc = NewDocument.objects.get(pk=old_german_doc.pk) + new_french_doc = NewDocument.objects.get(pk=old_french_doc.pk) + new_other_doc = NewDocument.objects.get(pk=old_other_doc.pk) + + assert new_english_doc.title == None + assert new_german_doc.title == None + assert new_french_doc.title == None + assert new_other_doc.title == "My document" diff --git a/submissions/devoteam/docs/src/backend/core/tests/migrations/test_migrations_0020_remove_is_public_add_field_attachments_and_duplicated_from.py b/submissions/devoteam/docs/src/backend/core/tests/migrations/test_migrations_0020_remove_is_public_add_field_attachments_and_duplicated_from.py new file mode 100644 index 00000000..f94e2a1e --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/migrations/test_migrations_0020_remove_is_public_add_field_attachments_and_duplicated_from.py @@ -0,0 +1,54 @@ +import base64 +import uuid + +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + +import pycrdt +import pytest + +from core import models + + +@pytest.mark.django_db +def test_populate_attachments_on_all_documents(migrator): + """Test that the migration populates attachments on existing documents.""" + old_state = migrator.apply_initial_migration( + ("core", "0019_alter_user_language_default_to_null") + ) + OldDocument = old_state.apps.get_model("core", "Document") + + old_doc_without_attachments = OldDocument.objects.create( + title="Doc without attachments", depth=1, path="0000002" + ) + old_doc_with_attachments = OldDocument.objects.create( + title="Doc with attachments", depth=1, path="0000001" + ) + + # Create document content with an image + file_key = f"{old_doc_with_attachments.id!s}/file" + image_key = f"{old_doc_with_attachments.id!s}/attachments/{uuid.uuid4()!s}.png" + ydoc = pycrdt.Doc() + fragment = pycrdt.XmlFragment( + [pycrdt.XmlElement("img", {"src": f"http://localhost/media/{image_key:s}"})] + ) + ydoc["document-store"] = fragment + update = ydoc.get_update() + base64_content = base64.b64encode(update).decode("utf-8") + bytes_content = base64_content.encode("utf-8") + content_file = ContentFile(bytes_content) + default_storage.save(file_key, content_file) + + # Apply the migration + new_state = migrator.apply_tested_migration( + ("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from") + ) + NewDocument = new_state.apps.get_model("core", "Document") + + new_doc_with_attachments = NewDocument.objects.get(pk=old_doc_with_attachments.pk) + new_doc_without_attachments = NewDocument.objects.get( + pk=old_doc_without_attachments.pk + ) + + assert new_doc_without_attachments.attachments == [] + assert new_doc_with_attachments.attachments == [image_key] diff --git a/submissions/devoteam/docs/src/backend/core/tests/swagger/__init__.py b/submissions/devoteam/docs/src/backend/core/tests/swagger/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/core/tests/swagger/test_openapi_schema.py b/submissions/devoteam/docs/src/backend/core/tests/swagger/test_openapi_schema.py new file mode 100644 index 00000000..74b0f183 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/swagger/test_openapi_schema.py @@ -0,0 +1,42 @@ +""" +Test suite for generated openapi schema. +""" + +import json +from io import StringIO + +from django.core.management import call_command +from django.test import Client + +import pytest + +pytestmark = pytest.mark.django_db + + +def test_openapi_client_schema(): + """ + Generated and served OpenAPI client schema should be correct. + """ + # Start by generating the swagger.json file + output = StringIO() + call_command( + "spectacular", + "--api-version", + "v1.0", + "--urlconf", + "core.urls", + "--format", + "openapi-json", + "--file", + "core/tests/swagger/swagger.json", + stdout=output, + ) + assert output.getvalue() == "" + + response = Client().get("/api/v1.0/swagger.json") + + assert response.status_code == 200 + with open( + "core/tests/swagger/swagger.json", "r", encoding="utf-8" + ) as expected_schema: + assert response.json() == json.load(expected_schema) diff --git a/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_template_accesses.py b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_template_accesses.py new file mode 100644 index 00000000..86e5f2bd --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_template_accesses.py @@ -0,0 +1,780 @@ +""" +Test template accesses API endpoints for users in impress's core app. +""" + +import random +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_template_accesses_list_anonymous(): + """Anonymous users should not be allowed to list template accesses.""" + template = factories.TemplateFactory() + factories.UserTemplateAccessFactory.create_batch(2, template=template) + + response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_template_accesses_list_authenticated_unrelated(): + """ + Authenticated users should not be allowed to list template accesses for a template + to which they are not related. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + factories.UserTemplateAccessFactory.create_batch(3, template=template) + + # Accesses for other templates to which the user is related should not be listed either + other_access = factories.UserTemplateAccessFactory(user=user) + factories.UserTemplateAccessFactory(template=other_access.template) + + response = client.get( + f"/api/v1.0/templates/{template.id!s}/accesses/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_list_authenticated_related(via, mock_user_teams): + """ + Authenticated users should be able to list template accesses for a template + to which they are directly related, whatever their role in the template. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + user_access = None + if via == USER: + user_access = models.TemplateAccess.objects.create( + template=template, + user=user, + role=random.choice(models.RoleChoices.values), + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + user_access = models.TemplateAccess.objects.create( + template=template, + team="lasuite", + role=random.choice(models.RoleChoices.values), + ) + + access1 = factories.TeamTemplateAccessFactory(template=template) + access2 = factories.UserTemplateAccessFactory(template=template) + + # Accesses for other templates to which the user is related should not be listed either + other_access = factories.UserTemplateAccessFactory(user=user) + factories.UserTemplateAccessFactory(template=other_access.template) + + response = client.get( + f"/api/v1.0/templates/{template.id!s}/accesses/", + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 3 + assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + [ + { + "id": str(user_access.id), + "user": str(user.id) if via == "user" else None, + "team": "lasuite" if via == "team" else "", + "role": user_access.role, + "abilities": user_access.get_abilities(user), + }, + { + "id": str(access1.id), + "user": None, + "team": access1.team, + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": str(access2.user.id), + "team": "", + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + ], + key=lambda x: x["id"], + ) + + +def test_api_template_accesses_retrieve_anonymous(): + """ + Anonymous users should not be allowed to retrieve a template access. + """ + access = factories.UserTemplateAccessFactory() + + response = APIClient().get( + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_template_accesses_retrieve_authenticated_unrelated(): + """ + Authenticated users should not be allowed to retrieve a template access for + a template to which they are not related. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + access = factories.UserTemplateAccessFactory(template=template) + + response = client.get( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Accesses related to another template should be excluded even if the user is related to it + for access in [ + factories.UserTemplateAccessFactory(), + factories.UserTemplateAccessFactory(user=user), + ]: + response = client.get( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 404 + assert response.json() == { + "detail": "No TemplateAccess matches the given query." + } + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams): + """ + A user who is related to a template should be allowed to retrieve the + associated template user accesses. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory(template=template, team="lasuite") + + access = factories.UserTemplateAccessFactory(template=template) + + response = client.get( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(access.id), + "user": str(access.user.id), + "team": "", + "role": access.role, + "abilities": access.get_abilities(user), + } + + +def test_api_template_accesses_update_anonymous(): + """Anonymous users should not be allowed to update a template access.""" + access = factories.UserTemplateAccessFactory() + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 401 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_template_accesses_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a template access for a template to which + they are not related. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + access = factories.UserTemplateAccessFactory() + + old_values = serializers.TemplateAccessSerializer(instance=access).data + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("role", ["reader", "editor"]) +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_update_authenticated_editor_or_reader( + via, role, mock_user_teams +): + """Editors or readers of a template should not be allowed to update its accesses.""" + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role=role + ) + + access = factories.UserTemplateAccessFactory(template=template) + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams): + """ + A user who is a direct administrator in a template should be allowed to update a user + access for this template, as long as they don't try to set the role to owner. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory( + template=template, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="administrator" + ) + + access = factories.UserTemplateAccessFactory( + template=template, + role=random.choice(["administrator", "editor", "reader"]), + ) + + old_values = serializers.TemplateAccessSerializer(instance=access).data + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(["administrator", "editor", "reader"]), + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams): + """ + A user who is an administrator in a template, should not be allowed to update + the user access of an "owner" for this template. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory( + template=template, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory() + access = factories.UserTemplateAccessFactory( + template=template, user=other_user, role="owner" + ) + + old_values = serializers.TemplateAccessSerializer(instance=access).data + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data={**old_values, field: value}, + format="json", + ) + + assert response.status_code == 403 + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams): + """ + A user who is an administrator in a template, should not be allowed to update + the user access of another user to grant template ownership. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory( + template=template, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory() + access = factories.UserTemplateAccessFactory( + template=template, + user=other_user, + role=random.choice(["administrator", "editor", "reader"]), + ) + + old_values = serializers.TemplateAccessSerializer(instance=access).data + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": "owner", + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + # We are not allowed or not really updating the role + if field == "role" or new_data["role"] == old_values["role"]: + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_update_owner(via, mock_user_teams): + """ + A user who is an owner in a template should be allowed to update + a user access for this template whatever the role. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="owner" + ) + + factories.UserFactory() + access = factories.UserTemplateAccessFactory( + template=template, + ) + + old_values = serializers.TemplateAccessSerializer(instance=access).data + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.values), + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_update_owner_self(via, mock_user_teams): + """ + A user who is owner of a template should be allowed to update + their own user access provided there are other owners in the template. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="owner" + ) + else: + access = factories.UserTemplateAccessFactory( + template=template, user=user, role="owner" + ) + + old_values = serializers.TemplateAccessSerializer(instance=access).data + new_role = random.choice(["administrator", "editor", "reader"]) + + response = client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 403 + access.refresh_from_db() + assert access.role == "owner" + + # Add another owner and it should now work + factories.UserTemplateAccessFactory(template=template, role="owner") + + response = client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role + + +# Delete + + +def test_api_template_accesses_delete_anonymous(): + """Anonymous users should not be allowed to destroy a template access.""" + access = factories.UserTemplateAccessFactory() + + response = APIClient().delete( + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert models.TemplateAccess.objects.count() == 1 + + +def test_api_template_accesses_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a template access for a + template to which they are not related. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + access = factories.UserTemplateAccessFactory() + + response = client.delete( + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.TemplateAccess.objects.count() == 2 + + +@pytest.mark.parametrize("role", ["reader", "editor"]) +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams): + """ + Authenticated users should not be allowed to delete a template access for a + template in which they are a simple editor or reader. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role=role + ) + + access = factories.UserTemplateAccessFactory(template=template) + + assert models.TemplateAccess.objects.count() == 3 + assert models.TemplateAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.TemplateAccess.objects.count() == 3 + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_delete_administrators_except_owners( + via, mock_user_teams +): + """ + Users who are administrators in a template should be allowed to delete an access + from the template provided it is not ownership. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory( + template=template, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="administrator" + ) + + access = factories.UserTemplateAccessFactory( + template=template, role=random.choice(["reader", "editor", "administrator"]) + ) + + assert models.TemplateAccess.objects.count() == 2 + assert models.TemplateAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.TemplateAccess.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams): + """ + Users who are administrators in a template should not be allowed to delete an ownership + access from the template. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory( + template=template, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="administrator" + ) + + access = factories.UserTemplateAccessFactory(template=template, role="owner") + + assert models.TemplateAccess.objects.count() == 3 + assert models.TemplateAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.TemplateAccess.objects.count() == 3 + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_delete_owners(via, mock_user_teams): + """ + Users should be able to delete the template access of another user + for a template of which they are owner. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="owner" + ) + + access = factories.UserTemplateAccessFactory(template=template) + + assert models.TemplateAccess.objects.count() == 2 + assert models.TemplateAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.TemplateAccess.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams): + """ + It should not be possible to delete the last owner access from a template + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + access = None + if via == USER: + access = factories.UserTemplateAccessFactory( + template=template, user=user, role="owner" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="owner" + ) + + assert models.TemplateAccess.objects.count() == 2 + response = client.delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.TemplateAccess.objects.count() == 2 diff --git a/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_template_accesses_create.py b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_template_accesses_create.py new file mode 100644 index 00000000..f52a5344 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_template_accesses_create.py @@ -0,0 +1,206 @@ +""" +Test template accesses create API endpoint for users in impress's core app. +""" + +import random + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_template_accesses_create_anonymous(): + """Anonymous users should not be allowed to create template accesses.""" + template = factories.TemplateFactory() + + other_user = factories.UserFactory() + response = APIClient().post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "template": str(template.id), + "role": random.choice(models.RoleChoices.values), + }, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + assert models.TemplateAccess.objects.exists() is False + + +def test_api_template_accesses_create_authenticated_unrelated(): + """ + Authenticated users should not be allowed to create template accesses for a template to + which they are not related. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + template = factories.TemplateFactory() + + response = client.post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + }, + format="json", + ) + + assert response.status_code == 403 + assert not models.TemplateAccess.objects.filter(user=other_user).exists() + + +@pytest.mark.parametrize("role", ["reader", "editor"]) +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_create_authenticated_editor_or_reader( + via, role, mock_user_teams +): + """Editors or readers of a template should not be allowed to create template accesses.""" + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role=role + ) + + other_user = factories.UserFactory() + + for new_role in [role[0] for role in models.RoleChoices.choices]: + response = client.post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "role": new_role, + }, + format="json", + ) + + assert response.status_code == 403 + + assert not models.TemplateAccess.objects.filter(user=other_user).exists() + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams): + """ + Administrators of a template should be able to create template accesses + except for the "owner" role. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory( + template=template, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory() + + # It should not be allowed to create an owner access + response = client.post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "role": "owner", + }, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Only owners of a resource can assign other users as owners." + } + + # It should be allowed to create a lower access + role = random.choice( + [role[0] for role in models.RoleChoices.choices if role[0] != "owner"] + ) + + response = client.post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.TemplateAccess.objects.filter(user=other_user).count() == 1 + new_template_access = models.TemplateAccess.objects.filter(user=other_user).get() + assert response.json() == { + "abilities": new_template_access.get_abilities(user), + "id": str(new_template_access.id), + "team": "", + "role": role, + "user": str(other_user.id), + } + + +@pytest.mark.parametrize("via", VIA) +def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams): + """ + Owners of a template should be able to create template accesses whatever the role. + """ + user = factories.UserFactory(with_owned_template=True) + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="owner" + ) + + other_user = factories.UserFactory() + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + response = client.post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.TemplateAccess.objects.filter(user=other_user).count() == 1 + new_template_access = models.TemplateAccess.objects.filter(user=other_user).get() + assert response.json() == { + "id": str(new_template_access.id), + "user": str(other_user.id), + "team": "", + "role": role, + "abilities": new_template_access.get_abilities(user), + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_create.py b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_create.py new file mode 100644 index 00000000..75dddc82 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_create.py @@ -0,0 +1,48 @@ +""" +Tests for Templates API endpoint in impress's core app: create +""" + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Template + +pytestmark = pytest.mark.django_db + + +def test_api_templates_create_anonymous(): + """Anonymous users should not be allowed to create templates.""" + response = APIClient().post( + "/api/v1.0/templates/", + { + "title": "my template", + }, + ) + + assert response.status_code == 401 + assert not Template.objects.exists() + + +def test_api_templates_create_authenticated(): + """ + Authenticated users should be able to create templates and should automatically be declared + as the owner of the newly created template. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/templates/", + { + "title": "my template", + }, + format="json", + ) + + assert response.status_code == 201 + template = Template.objects.get() + assert template.title == "my template" + assert template.accesses.filter(role="owner", user=user).exists() diff --git a/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_delete.py b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_delete.py new file mode 100644 index 00000000..5c4005e4 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_delete.py @@ -0,0 +1,107 @@ +""" +Tests for Templates API endpoint in impress's core app: delete +""" + +import random + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_templates_delete_anonymous(): + """Anonymous users should not be allowed to destroy a template.""" + template = factories.TemplateFactory() + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id!s}/", + ) + + assert response.status_code == 401 + assert models.Template.objects.count() == 1 + + +def test_api_templates_delete_authenticated_unrelated(): + """ + Authenticated users should not be allowed to delete a template to which they are not + related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + is_public = random.choice([True, False]) + template = factories.TemplateFactory(is_public=is_public) + + response = client.delete( + f"/api/v1.0/templates/{template.id!s}/", + ) + + assert response.status_code == 403 if is_public else 404 + assert models.Template.objects.count() == 1 + + +@pytest.mark.parametrize("role", ["reader", "editor", "administrator"]) +@pytest.mark.parametrize("via", VIA) +def test_api_templates_delete_authenticated_member_or_administrator( + via, role, mock_user_teams +): + """ + Authenticated users should not be allowed to delete a template for which they are + only a member or administrator. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role=role + ) + + response = client.delete( + f"/api/v1.0/templates/{template.id}/", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.Template.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_templates_delete_authenticated_owner(via, mock_user_teams): + """ + Authenticated users should be able to delete a template they own. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="owner" + ) + + response = client.delete( + f"/api/v1.0/templates/{template.id}/", + ) + + assert response.status_code == 204 + assert models.Template.objects.exists() is False diff --git a/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_list.py b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_list.py new file mode 100644 index 00000000..11df4fa9 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_list.py @@ -0,0 +1,220 @@ +""" +Tests for Templates API endpoint in impress's core app: list +""" + +from unittest import mock + +import pytest +from rest_framework.pagination import PageNumberPagination +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_templates_list_anonymous(): + """Anonymous users should only be able to list public templates.""" + factories.TemplateFactory.create_batch(2, is_public=False) + public_templates = factories.TemplateFactory.create_batch(2, is_public=True) + expected_ids = {str(template.id) for template in public_templates} + + response = APIClient().get("/api/v1.0/templates/") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +def test_api_templates_list_authenticated_direct(): + """ + Authenticated users should be able to list templates they are a direct + owner/administrator/member of or that are public. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + related_templates = [ + access.template + for access in factories.UserTemplateAccessFactory.create_batch(5, user=user) + ] + public_templates = factories.TemplateFactory.create_batch(2, is_public=True) + factories.TemplateFactory.create_batch(2, is_public=False) + + expected_ids = { + str(template.id) for template in related_templates + public_templates + } + + response = client.get( + "/api/v1.0/templates/", + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 7 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +def test_api_templates_list_authenticated_via_team(mock_user_teams): + """ + Authenticated users should be able to list templates they are a + owner/administrator/member of via a team or that are public. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + mock_user_teams.return_value = ["team1", "team2", "unknown"] + + templates_team1 = [ + access.template + for access in factories.TeamTemplateAccessFactory.create_batch(2, team="team1") + ] + templates_team2 = [ + access.template + for access in factories.TeamTemplateAccessFactory.create_batch(3, team="team2") + ] + public_templates = factories.TemplateFactory.create_batch(2, is_public=True) + factories.TemplateFactory.create_batch(2, is_public=False) + + expected_ids = { + str(template.id) + for template in templates_team1 + templates_team2 + public_templates + } + + response = client.get("/api/v1.0/templates/") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 7 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_templates_list_pagination( + _mock_page_size, +): + """Pagination should work as expected.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template_ids = [ + str(access.template_id) + for access in factories.UserTemplateAccessFactory.create_batch(3, user=user) + ] + + # Get page 1 + response = client.get( + "/api/v1.0/templates/", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/templates/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + template_ids.remove(item["id"]) + + # Get page 2 + response = client.get( + "/api/v1.0/templates/?page=2", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"] == "http://testserver/api/v1.0/templates/" + + assert len(content["results"]) == 1 + template_ids.remove(content["results"][0]["id"]) + assert template_ids == [] + + +def test_api_templates_list_authenticated_distinct(): + """A template with several related users should only be listed once.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + + template = factories.TemplateFactory(users=[user, other_user], is_public=True) + + response = client.get( + "/api/v1.0/templates/", + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 1 + assert content["results"][0]["id"] == str(template.id) + + +def test_api_templates_list_order_default(): + """The templates list should be sorted by 'created_at' in descending order by default.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + template_ids = [ + str(access.template.id) + for access in factories.UserTemplateAccessFactory.create_batch(5, user=user) + ] + + response = client.get( + "/api/v1.0/templates/", + ) + + assert response.status_code == 200 + + response_data = response.json() + response_template_ids = [template["id"] for template in response_data["results"]] + + template_ids.reverse() + assert response_template_ids == template_ids, ( + "created_at values are not sorted from newest to oldest" + ) + + +def test_api_templates_list_order_param(): + """ + The templates list is sorted by 'created_at' in ascending order when setting + the "ordering" query parameter. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + templates_ids = [ + str(access.template.id) + for access in factories.UserTemplateAccessFactory.create_batch(5, user=user) + ] + + response = client.get( + "/api/v1.0/templates/?ordering=created_at", + ) + assert response.status_code == 200 + + response_data = response.json() + + response_template_ids = [template["id"] for template in response_data["results"]] + + assert response_template_ids == templates_ids, ( + "created_at values are not sorted from oldest to newest" + ) diff --git a/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_retrieve.py b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_retrieve.py new file mode 100644 index 00000000..e3466ab2 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_retrieve.py @@ -0,0 +1,522 @@ +""" +Tests for Templates API endpoint in impress's core app: retrieve +""" + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_templates_retrieve_anonymous_public(): + """Anonymous users should be allowed to retrieve public templates.""" + template = factories.TemplateFactory(is_public=True) + + response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(template.id), + "abilities": { + "destroy": False, + "generate_document": True, + "accesses_manage": False, + "partial_update": False, + "retrieve": True, + "update": False, + }, + "accesses": [], + "title": template.title, + "is_public": True, + "code": template.code, + "css": template.css, + } + + +def test_api_templates_retrieve_anonymous_not_public(): + """Anonymous users should not be able to retrieve a template that is not public.""" + template = factories.TemplateFactory(is_public=False) + + response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_templates_retrieve_authenticated_unrelated_public(): + """ + Authenticated users should be able to retrieve a public template to which they are + not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory(is_public=True) + + response = client.get( + f"/api/v1.0/templates/{template.id!s}/", + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(template.id), + "abilities": { + "destroy": False, + "generate_document": True, + "accesses_manage": False, + "partial_update": False, + "retrieve": True, + "update": False, + }, + "accesses": [], + "title": template.title, + "is_public": True, + "code": template.code, + "css": template.css, + } + + +def test_api_templates_retrieve_authenticated_unrelated_not_public(): + """ + Authenticated users should not be allowed to retrieve a template that is not public and + to which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory(is_public=False) + + response = client.get( + f"/api/v1.0/templates/{template.id!s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_templates_retrieve_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve a template to which they + are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + access1 = factories.UserTemplateAccessFactory(template=template, user=user) + access2 = factories.UserTemplateAccessFactory(template=template) + + response = client.get( + f"/api/v1.0/templates/{template.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted( + [ + { + "id": str(access1.id), + "user": str(user.id), + "team": "", + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": str(access2.user.id), + "team": "", + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + ], + key=lambda x: x["user"], + ) + assert response.json() == { + "id": str(template.id), + "title": template.title, + "abilities": template.get_abilities(user), + "is_public": template.is_public, + "code": template.code, + "css": template.css, + } + + +def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams): + """ + Authenticated users should not be able to retrieve a template related to teams in + which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory(is_public=False) + + factories.TeamTemplateAccessFactory( + template=template, team="readers", role="reader" + ) + factories.TeamTemplateAccessFactory( + template=template, team="editors", role="editor" + ) + factories.TeamTemplateAccessFactory( + template=template, team="administrators", role="administrator" + ) + factories.TeamTemplateAccessFactory(template=template, team="owners", role="owner") + factories.TeamTemplateAccessFactory(template=template) + factories.TeamTemplateAccessFactory() + + response = client.get(f"/api/v1.0/templates/{template.id!s}/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +@pytest.mark.parametrize( + "teams", + [ + ["readers"], + ["unknown", "readers"], + ["editors"], + ["unknown", "editors"], + ], +) +def test_api_templates_retrieve_authenticated_related_team_readers_or_editors( + teams, mock_user_teams +): + """ + Authenticated users should be allowed to retrieve a template to which they + are related via a team whatever the role and see all its accesses. + """ + mock_user_teams.return_value = teams + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory(is_public=False) + + access_reader = factories.TeamTemplateAccessFactory( + template=template, team="readers", role="reader" + ) + access_editor = factories.TeamTemplateAccessFactory( + template=template, team="editors", role="editor" + ) + access_administrator = factories.TeamTemplateAccessFactory( + template=template, team="administrators", role="administrator" + ) + access_owner = factories.TeamTemplateAccessFactory( + template=template, team="owners", role="owner" + ) + other_access = factories.TeamTemplateAccessFactory(template=template) + factories.TeamTemplateAccessFactory() + + response = client.get(f"/api/v1.0/templates/{template.id!s}/") + assert response.status_code == 200 + content = response.json() + expected_abilities = { + "destroy": False, + "retrieve": True, + "set_role_to": [], + "update": False, + "partial_update": False, + } + assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( + [ + { + "id": str(access_reader.id), + "user": None, + "team": "readers", + "role": access_reader.role, + "abilities": expected_abilities, + }, + { + "id": str(access_editor.id), + "user": None, + "team": "editors", + "role": access_editor.role, + "abilities": expected_abilities, + }, + { + "id": str(access_administrator.id), + "user": None, + "team": "administrators", + "role": access_administrator.role, + "abilities": expected_abilities, + }, + { + "id": str(access_owner.id), + "user": None, + "team": "owners", + "role": access_owner.role, + "abilities": expected_abilities, + }, + { + "id": str(other_access.id), + "user": None, + "team": other_access.team, + "role": other_access.role, + "abilities": expected_abilities, + }, + ], + key=lambda x: x["id"], + ) + assert response.json() == { + "id": str(template.id), + "title": template.title, + "abilities": template.get_abilities(user), + "is_public": False, + "code": template.code, + "css": template.css, + } + + +@pytest.mark.parametrize( + "teams", + [ + ["administrators"], + ["members", "administrators"], + ["unknown", "administrators"], + ], +) +def test_api_templates_retrieve_authenticated_related_team_administrators( + teams, mock_user_teams +): + """ + Authenticated users should be allowed to retrieve a template to which they + are related via a team whatever the role and see all its accesses. + """ + mock_user_teams.return_value = teams + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory(is_public=False) + + access_reader = factories.TeamTemplateAccessFactory( + template=template, team="readers", role="reader" + ) + access_editor = factories.TeamTemplateAccessFactory( + template=template, team="editors", role="editor" + ) + access_administrator = factories.TeamTemplateAccessFactory( + template=template, team="administrators", role="administrator" + ) + access_owner = factories.TeamTemplateAccessFactory( + template=template, team="owners", role="owner" + ) + other_access = factories.TeamTemplateAccessFactory(template=template) + factories.TeamTemplateAccessFactory() + + response = client.get(f"/api/v1.0/templates/{template.id!s}/") + + assert response.status_code == 200 + content = response.json() + assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( + [ + { + "id": str(access_reader.id), + "user": None, + "team": "readers", + "role": "reader", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["administrator", "editor"], + "update": True, + "partial_update": True, + }, + }, + { + "id": str(access_editor.id), + "user": None, + "team": "editors", + "role": "editor", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["administrator", "reader"], + "update": True, + "partial_update": True, + }, + }, + { + "id": str(access_administrator.id), + "user": None, + "team": "administrators", + "role": "administrator", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["editor", "reader"], + "update": True, + "partial_update": True, + }, + }, + { + "id": str(access_owner.id), + "user": None, + "team": "owners", + "role": "owner", + "abilities": { + "destroy": False, + "retrieve": True, + "set_role_to": [], + "update": False, + "partial_update": False, + }, + }, + { + "id": str(other_access.id), + "user": None, + "team": other_access.team, + "role": other_access.role, + "abilities": other_access.get_abilities(user), + }, + ], + key=lambda x: x["id"], + ) + assert response.json() == { + "id": str(template.id), + "title": template.title, + "abilities": template.get_abilities(user), + "is_public": False, + "code": template.code, + "css": template.css, + } + + +@pytest.mark.parametrize( + "teams", + [ + ["owners"], + ["owners", "administrators"], + ["members", "administrators", "owners"], + ["unknown", "owners"], + ], +) +def test_api_templates_retrieve_authenticated_related_team_owners( + teams, mock_user_teams +): + """ + Authenticated users should be allowed to retrieve a template to which they + are related via a team whatever the role and see all its accesses. + """ + mock_user_teams.return_value = teams + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory(is_public=False) + + access_reader = factories.TeamTemplateAccessFactory( + template=template, team="readers", role="reader" + ) + access_editor = factories.TeamTemplateAccessFactory( + template=template, team="editors", role="editor" + ) + access_administrator = factories.TeamTemplateAccessFactory( + template=template, team="administrators", role="administrator" + ) + access_owner = factories.TeamTemplateAccessFactory( + template=template, team="owners", role="owner" + ) + other_access = factories.TeamTemplateAccessFactory(template=template) + factories.TeamTemplateAccessFactory() + + response = client.get(f"/api/v1.0/templates/{template.id!s}/") + + assert response.status_code == 200 + content = response.json() + assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( + [ + { + "id": str(access_reader.id), + "user": None, + "team": "readers", + "role": "reader", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["owner", "administrator", "editor"], + "update": True, + "partial_update": True, + }, + }, + { + "id": str(access_editor.id), + "user": None, + "team": "editors", + "role": "editor", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["owner", "administrator", "reader"], + "update": True, + "partial_update": True, + }, + }, + { + "id": str(access_administrator.id), + "user": None, + "team": "administrators", + "role": "administrator", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["owner", "editor", "reader"], + "update": True, + "partial_update": True, + }, + }, + { + "id": str(access_owner.id), + "user": None, + "team": "owners", + "role": "owner", + "abilities": { + # editable only if there is another owner role than the user's team... + "destroy": other_access.role == "owner", + "retrieve": True, + "set_role_to": ["administrator", "editor", "reader"] + if other_access.role == "owner" + else [], + "update": other_access.role == "owner", + "partial_update": other_access.role == "owner", + }, + }, + { + "id": str(other_access.id), + "user": None, + "team": other_access.team, + "role": other_access.role, + "abilities": other_access.get_abilities(user), + }, + ], + key=lambda x: x["id"], + ) + assert response.json() == { + "id": str(template.id), + "title": template.title, + "abilities": template.get_abilities(user), + "is_public": False, + "code": template.code, + "css": template.css, + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_update.py b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_update.py new file mode 100644 index 00000000..7c5a27c6 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/templates/test_api_templates_update.py @@ -0,0 +1,230 @@ +""" +Tests for Templates API endpoint in impress's core app: update +""" + +import random + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_templates_update_anonymous(): + """Anonymous users should not be allowed to update a template.""" + template = factories.TemplateFactory() + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = APIClient().put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + assert template_values == old_template_values + + +def test_api_templates_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a template to which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory(is_public=False) + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = client.put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + assert template_values == old_template_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_templates_update_authenticated_readers(via, mock_user_teams): + """ + Users who are readers of a template should not be allowed to update it. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role="reader") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="reader" + ) + + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = client.put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + assert template_values == old_template_values + + +@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) +@pytest.mark.parametrize("via", VIA) +def test_api_templates_update_authenticated_editor_or_administrator_or_owner( + via, role, mock_user_teams +): + """Administrator or owner of a template should be allowed to update it.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role=role + ) + + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = client.put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + ) + assert response.status_code == 200 + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + for key, value in template_values.items(): + if key in ["id", "accesses"]: + assert value == old_template_values[key] + else: + assert value == new_template_values[key] + + +@pytest.mark.parametrize("via", VIA) +def test_api_templates_update_authenticated_owners(via, mock_user_teams): + """Administrators of a template should be allowed to update it.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory(template=template, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, team="lasuite", role="owner" + ) + + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + + response = client.put( + f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json" + ) + + assert response.status_code == 200 + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + for key, value in template_values.items(): + if key in ["id", "accesses"]: + assert value == old_template_values[key] + else: + assert value == new_template_values[key] + + +@pytest.mark.parametrize("via", VIA) +def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams): + """ + Being administrator or owner of a template should not grant authorization to update + another template. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory() + if via == USER: + factories.UserTemplateAccessFactory( + template=template, user=user, role=random.choice(["administrator", "owner"]) + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamTemplateAccessFactory( + template=template, + team="lasuite", + role=random.choice(["administrator", "owner"]), + ) + + is_public = random.choice([True, False]) + template = factories.TemplateFactory(title="Old title", is_public=is_public) + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = client.put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + ) + + assert response.status_code == 403 if is_public else 404 + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + assert template_values == old_template_values diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_api_config.py b/submissions/devoteam/docs/src/backend/core/tests/test_api_config.py new file mode 100644 index 00000000..2d74594c --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_api_config.py @@ -0,0 +1,157 @@ +""" +Test config API endpoints in the Impress core app. +""" + +import json + +from django.test import override_settings + +import pytest +from rest_framework.status import ( + HTTP_200_OK, +) +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +@override_settings( + AI_FEATURE_ENABLED=False, + COLLABORATION_WS_URL="http://testcollab/", + COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True, + CRISP_WEBSITE_ID="123", + FRONTEND_CSS_URL="http://testcss/", + FRONTEND_THEME="test-theme", + MEDIA_BASE_URL="http://testserver/", + POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"}, + SENTRY_DSN="https://sentry.test/123", + THEME_CUSTOMIZATION_FILE_PATH="", +) +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config(is_authenticated): + """Anonymous users should be allowed to get the configuration.""" + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + assert response.json() == { + "COLLABORATION_WS_URL": "http://testcollab/", + "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, + "CRISP_WEBSITE_ID": "123", + "ENVIRONMENT": "test", + "FRONTEND_CSS_URL": "http://testcss/", + "FRONTEND_HOMEPAGE_FEATURE_ENABLED": True, + "FRONTEND_THEME": "test-theme", + "LANGUAGES": [ + ["en-us", "English"], + ["fr-fr", "Français"], + ["de-de", "Deutsch"], + ["nl-nl", "Nederlands"], + ["es-es", "Español"], + ], + "LANGUAGE_CODE": "en-us", + "MEDIA_BASE_URL": "http://testserver/", + "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, + "SENTRY_DSN": "https://sentry.test/123", + "AI_FEATURE_ENABLED": False, + "theme_customization": {}, + } + + +@override_settings( + THEME_CUSTOMIZATION_FILE_PATH="/not/existing/file.json", +) +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config_with_invalid_theme_customization_file(is_authenticated): + """Anonymous users should be allowed to get the configuration.""" + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + content = response.json() + assert content["theme_customization"] == {} + + +@override_settings( + THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/invalid.json", +) +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config_with_invalid_json_theme_customization_file(is_authenticated, fs): + """Anonymous users should be allowed to get the configuration.""" + fs.create_file( + "/configuration/theme/invalid.json", + contents="invalid json", + ) + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + content = response.json() + assert content["theme_customization"] == {} + + +@override_settings( + THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/default.json", +) +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config_with_theme_customization(is_authenticated, fs): + """Anonymous users should be allowed to get the configuration.""" + fs.create_file( + "/configuration/theme/default.json", + contents=json.dumps( + { + "colors": { + "primary": "#000000", + "secondary": "#000000", + }, + } + ), + ) + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + content = response.json() + assert content["theme_customization"] == { + "colors": { + "primary": "#000000", + "secondary": "#000000", + }, + } + + +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config_with_original_theme_customization(is_authenticated, settings): + """Anonymous users should be allowed to get the configuration.""" + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + content = response.json() + + with open(settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8") as f: + theme_customization = json.load(f) + + assert content["theme_customization"] == theme_customization diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_api_users.py b/submissions/devoteam/docs/src/backend/core/tests/test_api_users.py new file mode 100644 index 00000000..91863dc5 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_api_users.py @@ -0,0 +1,574 @@ +""" +Test users API endpoints in the impress core app. +""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers + +pytestmark = pytest.mark.django_db + + +def test_api_users_list_anonymous(): + """Anonymous users should not be allowed to list users.""" + factories.UserFactory() + client = APIClient() + response = client.get("/api/v1.0/users/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_users_list_authenticated(): + """ + Authenticated users should not be able to list users without a query. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + factories.UserFactory.create_batch(2) + response = client.get( + "/api/v1.0/users/", + ) + assert response.status_code == 200 + content = response.json() + assert content == [] + + +def test_api_users_list_query_email(): + """ + Authenticated users should be able to list users and filter by email. + Only results with a Levenstein distance less than 3 with the query should be returned. + We want to match by Levenstein distance because we want to prevent typing errors. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + dave = factories.UserFactory(email="david.bowman@work.com") + factories.UserFactory(email="nicole.bowman@work.com") + + response = client.get( + "/api/v1.0/users/?q=david.bowman@work.com", + ) + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()] + assert user_ids == [str(dave.id)] + + response = client.get( + "/api/v1.0/users/?q=davig.bovman@worm.com", + ) + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()] + assert user_ids == [str(dave.id)] + + response = client.get( + "/api/v1.0/users/?q=davig.bovman@worm.cop", + ) + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()] + assert user_ids == [] + + +def test_api_users_list_limit(settings): + """ + Authenticated users should be able to list users and the number of results + should be limited to 10. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + # Use a base name with a length equal 5 to test that the limit is applied + base_name = "alice" + for i in range(15): + factories.UserFactory(email=f"{base_name}.{i}@example.com") + + response = client.get( + "/api/v1.0/users/?q=alice", + ) + assert response.status_code == 200 + assert len(response.json()) == 5 + + # if the limit is changed, all users should be returned + settings.API_USERS_LIST_LIMIT = 100 + response = client.get( + "/api/v1.0/users/?q=alice", + ) + assert response.status_code == 200 + assert len(response.json()) == 15 + + +def test_api_users_list_throttling_authenticated(settings): + """ + Authenticated users should be throttled. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "3/minute" + + for _i in range(3): + response = client.get( + "/api/v1.0/users/?q=alice", + ) + assert response.status_code == 200 + + response = client.get( + "/api/v1.0/users/?q=alice", + ) + assert response.status_code == 429 + + +def test_api_users_list_query_email_matching(): + """While filtering by email, results should be filtered and sorted by Levenstein distance.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr") + user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr") + user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr") + user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr") + user5 = factories.UserFactory(email="alicia.johnnson@example.gov.uk") + factories.UserFactory(email="alice.thomson@example.gouv.fr") + + response = client.get( + "/api/v1.0/users/?q=alice.johnson@example.gouv.fr", + ) + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()] + assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)] + + response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr") + + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()] + assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)] + + +def test_api_users_list_query_email_exclude_doc_user(): + """ + Authenticated users should be able to list users while filtering by email + and excluding users who have access to a document. + """ + user = factories.UserFactory() + document = factories.DocumentFactory() + + client = APIClient() + client.force_login(user) + + nicole_fool = factories.UserFactory(email="nicole_fool@work.com") + nicole_pool = factories.UserFactory(email="nicole_pool@work.com") + factories.UserFactory(email="heywood_floyd@work.com") + + factories.UserDocumentAccessFactory(document=document, user=nicole_pool) + + response = client.get( + "/api/v1.0/users/?q=nicole_fool@work.com&document_id=" + str(document.id) + ) + + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()] + assert user_ids == [str(nicole_fool.id)] + + +def test_api_users_list_query_short_queries(): + """ + Queries shorter than 5 characters should return an empty result set. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.UserFactory(email="john.doe@example.com") + factories.UserFactory(email="john.lennon@example.com") + + response = client.get("/api/v1.0/users/?q=jo") + assert response.status_code == 200 + assert response.json() == [] + + response = client.get("/api/v1.0/users/?q=john") + assert response.status_code == 200 + assert response.json() == [] + + response = client.get("/api/v1.0/users/?q=john.") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +def test_api_users_list_query_inactive(): + """Inactive users should not be listed.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.UserFactory(email="john.doe@example.com", is_active=False) + lennon = factories.UserFactory(email="john.lennon@example.com") + + response = client.get("/api/v1.0/users/?q=john.") + + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()] + assert user_ids == [str(lennon.id)] + + +def test_api_users_retrieve_me_anonymous(): + """Anonymous users should not be allowed to list users.""" + factories.UserFactory.create_batch(2) + client = APIClient() + response = client.get("/api/v1.0/users/me/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_users_retrieve_me_authenticated(): + """Authenticated users should be able to retrieve their own user via the "/users/me" path.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + factories.UserFactory.create_batch(2) + response = client.get( + "/api/v1.0/users/me/", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(user.id), + "email": user.email, + "full_name": user.full_name, + "language": user.language, + "short_name": user.short_name, + } + + +def test_api_users_retrieve_anonymous(): + """Anonymous users should not be allowed to retrieve a user.""" + client = APIClient() + user = factories.UserFactory() + response = client.get(f"/api/v1.0/users/{user.id!s}/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_users_retrieve_authenticated_self(): + """ + Authenticated users should be allowed to retrieve their own user. + The returned object should not contain the password. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/users/{user.id!s}/", + ) + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "GET" not allowed.'} + + +def test_api_users_retrieve_authenticated_other(): + """ + Authenticated users should be able to retrieve another user's detail view with + limited information. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + + response = client.get( + f"/api/v1.0/users/{other_user.id!s}/", + ) + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "GET" not allowed.'} + + +def test_api_users_create_anonymous(): + """Anonymous users should not be able to create users via the API.""" + response = APIClient().post( + "/api/v1.0/users/", + { + "language": "fr-fr", + "password": "mypassword", + }, + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + assert models.User.objects.exists() is False + + +def test_api_users_create_authenticated(): + """Authenticated users should not be able to create users via the API.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/users/", + { + "language": "fr-fr", + "password": "mypassword", + }, + format="json", + ) + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "POST" not allowed.'} + assert models.User.objects.exclude(id=user.id).exists() is False + + +def test_api_users_update_anonymous(): + """Anonymous users should not be able to update users via the API.""" + user = factories.UserFactory() + + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + response = APIClient().put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_update_authenticated_self(): + """ + Authenticated users should be able to update their own user but only "language" + and "timezone" fields. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = dict( + serializers.UserSerializer(instance=factories.UserFactory()).data + ) + + response = client.put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + ) + + assert response.status_code == 200 + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + if key in ["language", "timezone"]: + assert value == new_user_values[key] + else: + assert value == old_user_values[key] + + +def test_api_users_update_authenticated_other(): + """Authenticated users should not be allowed to update other users.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + user = factories.UserFactory() + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + response = client.put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + ) + + assert response.status_code == 403 + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_patch_anonymous(): + """Anonymous users should not be able to patch users via the API.""" + user = factories.UserFactory() + + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = dict( + serializers.UserSerializer(instance=factories.UserFactory()).data + ) + + for key, new_value in new_user_values.items(): + response = APIClient().patch( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_patch_authenticated_self(): + """ + Authenticated users should be able to patch their own user but only "language" + and "timezone" fields. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = dict( + serializers.UserSerializer(instance=factories.UserFactory()).data + ) + + for key, new_value in new_user_values.items(): + response = client.patch( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + ) + assert response.status_code == 200 + + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + if key in ["language", "timezone"]: + assert value == new_user_values[key] + else: + assert value == old_user_values[key] + + +def test_api_users_patch_authenticated_other(): + """Authenticated users should not be allowed to patch other users.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + user = factories.UserFactory() + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = dict( + serializers.UserSerializer(instance=factories.UserFactory()).data + ) + + for key, new_value in new_user_values.items(): + response = client.put( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + ) + assert response.status_code == 403 + + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_delete_list_anonymous(): + """Anonymous users should not be allowed to delete a list of users.""" + factories.UserFactory.create_batch(2) + + client = APIClient() + response = client.delete("/api/v1.0/users/") + + assert response.status_code == 401 + assert models.User.objects.count() == 2 + + +def test_api_users_delete_list_authenticated(): + """Authenticated users should not be allowed to delete a list of users.""" + factories.UserFactory.create_batch(2) + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.delete( + "/api/v1.0/users/", + ) + + assert response.status_code == 405 + assert models.User.objects.count() == 3 + + +def test_api_users_delete_anonymous(): + """Anonymous users should not be allowed to delete a user.""" + user = factories.UserFactory() + + response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/") + + assert response.status_code == 401 + assert models.User.objects.count() == 1 + + +def test_api_users_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a user other than themselves. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + + response = client.delete( + f"/api/v1.0/users/{other_user.id!s}/", + ) + + assert response.status_code == 405 + assert models.User.objects.count() == 2 + + +def test_api_users_delete_self(): + """Authenticated users should not be able to delete their own user.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/users/{user.id!s}/", + ) + + assert response.status_code == 405 + assert models.User.objects.count() == 1 diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_ai_document_rate_throttles.py b/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_ai_document_rate_throttles.py new file mode 100644 index 00000000..7ab83bb9 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_ai_document_rate_throttles.py @@ -0,0 +1,119 @@ +""" +Test throttling on documents for the AI endpoint. +""" + +from unittest.mock import patch + +from django.test import override_settings + +from rest_framework.response import Response +from rest_framework.test import APIRequestFactory +from rest_framework.views import APIView + +from core.api.utils import AIDocumentRateThrottle + + +class DocumentAPIView(APIView): + """A simple view to test the throttle""" + + throttle_classes = [AIDocumentRateThrottle] + + def get(self, request, *args, **kwargs): + """Minimal get method for testing purposes.""" + return Response({"message": "Success"}) + + +@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@patch("time.time") +def test_api_utils_ai_document_rate_throttle_minute_limit(mock_time): + """Test that minute limit is enforced.""" + api_rf = APIRequestFactory() + mock_time.return_value = 1000000 + + # Simulate requests to the document API + for _i in range(3): # 3 first requests should be allowed + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 200 + + # Simulate passage of time + mock_time.return_value += 59 + + # 4th request should be throttled + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 429 + + # After the 60s backoff wait time has passed, we can make a request again + mock_time.return_value += 1 + + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 200 + + +@override_settings( + AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10} +) +@patch("time.time") +def test_ai_document_rate_throttle_hour_limit(mock_time): + """Test that the hour limit is enforced without hitting the minute limit.""" + api_rf = APIRequestFactory() + mock_time.return_value = 1000000 + + # Make requests to the document API, one per 21 seconds to avoid hitting the minute limit + for _i in range(6): + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 200 + + # Simulate passage of time + mock_time.return_value += 21 + + # Simulate passage of time + mock_time.return_value += 3600 - 6 * 21 - 1 + + # 7th request should be throttled + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 429 + + # After the 1h backoff wait time has passed, we can make a request again + mock_time.return_value += 1 + + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 200 + + +@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@patch("time.time") +def test_api_utils_ai_document_rate_throttle_day_limit(mock_time): + """Test that day limit is enforced.""" + api_rf = APIRequestFactory() + mock_time.return_value = 1000000 + + # Make requests to the document API, one per 10 minutes to avoid hitting + # the minute and hour limits + for _i in range(10): # 10 requests should be allowed + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 200 + + # Simulate passage of time + mock_time.return_value += 60 * 10 + + # Simulate passage of time + mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1 + + # 11th request should be throttled + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 429 + + # After the 24h backoff wait time has passed we can make a request again + mock_time.return_value += 1 + + request = api_rf.get("/documents/1/") + response = DocumentAPIView.as_view()(request, pk=1) + assert response.status_code == 200 diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_ai_user_rate_throttles.py b/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_ai_user_rate_throttles.py new file mode 100644 index 00000000..01a25db4 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_ai_user_rate_throttles.py @@ -0,0 +1,139 @@ +""" +Test throttling on users for the AI endpoint. +""" + +from unittest.mock import patch +from uuid import uuid4 + +from django.test import override_settings + +import pytest +from rest_framework.response import Response +from rest_framework.test import APIRequestFactory +from rest_framework.views import APIView + +from core.api.utils import AIUserRateThrottle +from core.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class DocumentAPIView(APIView): + """A simple view to test the throttle""" + + throttle_classes = [AIUserRateThrottle] + + def get(self, request, *args, **kwargs): + """Minimal get method for testing purposes.""" + return Response({"message": "Success"}) + + +@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@patch("time.time") +def test_api_utils_ai_user_rate_throttle_minute_limit(mock_time): + """Test that minute limit is enforced.""" + user = UserFactory() + api_rf = APIRequestFactory() + mock_time.return_value = 1000000 + + # Simulate requests to the document API + for _i in range(3): # 3 first requests should be allowed + document_id = str(uuid4()) + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 200 + + # Simulate passage of time + mock_time.return_value += 59 + + # 4th request should be throttled + document_id = str(uuid4()) + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 429 + + # After the 60s backoff wait time has passed, we can make a request again + mock_time.return_value += 1 + + document_id = str(uuid4()) + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 200 + + +@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10}) +@patch("time.time") +def test_ai_user_rate_throttle_hour_limit(mock_time): + """Test that the hour limit is enforced without hitting the minute limit.""" + user = UserFactory() + api_rf = APIRequestFactory() + mock_time.return_value = 1000000 + + # Make requests to the document API, one per 21 seconds to avoid hitting the minute limit + for _i in range(6): + document_id = str(uuid4()) + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 200 + + # Simulate passage of time + mock_time.return_value += 21 + + # Simulate passage of time + mock_time.return_value += 3600 - 6 * 21 - 1 + + # 7th request should be throttled + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 429 + + # After the 1h backoff wait time has passed, we can make a request again + mock_time.return_value += 1 + + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 200 + + +@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@patch("time.time") +def test_api_utils_ai_user_rate_throttle_day_limit(mock_time): + """Test that day limit is enforced.""" + user = UserFactory() + api_rf = APIRequestFactory() + mock_time.return_value = 1000000 + + # Make requests to the document API, one per 10 minutes to avoid hitting + # the minute and hour limits + for _i in range(10): # 10 requests should be allowed + document_id = str(uuid4()) + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 200 + + # Simulate passage of time + mock_time.return_value += 60 * 10 + + # Simulate passage of time + mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1 + + # 11th request should be throttled + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 429 + + # After the 24h backoff wait time has passed we can make a request again + mock_time.return_value += 1 + + request = api_rf.get(f"/documents/{document_id:s}/") + request.user = user + response = DocumentAPIView.as_view()(request, pk=document_id) + assert response.status_code == 200 diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_filter_root_paths.py b/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_filter_root_paths.py new file mode 100644 index 00000000..1375d223 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_filter_root_paths.py @@ -0,0 +1,94 @@ +""" +Unit tests for the filter_root_paths utility function. +""" + +from core.api.utils import filter_root_paths + + +def test_api_utils_filter_root_paths_success(): + """ + The `filter_root_paths` function should correctly identify root paths + from a given list of paths. + + This test uses a list of paths with missing intermediate paths to ensure that + only the minimal set of root paths is returned. + """ + paths = [ + "0001", + "00010001", + "000100010001", + "000100010002", + # missing 00010002 + "000100020001", + "000100020002", + "0002", + "00020001", + "00020002", + # missing 0003 + "00030001", + "000300010001", + "00030002", + # missing 0004 + # missing 00040001 + # missing 000400010001 + # missing 000400010002 + "000400010003", + "0004000100030001", + "000400010004", + ] + filtered_paths = filter_root_paths(paths, skip_sorting=True) + assert filtered_paths == [ + "0001", + "0002", + "00030001", + "00030002", + "000400010003", + "000400010004", + ] + + +def test_api_utils_filter_root_paths_sorting(): + """ + The `filter_root_paths` function should fail is sorting is skipped and paths are not sorted. + + This test verifies that when sorting is skipped, the function respects the input order, and + when sorting is enabled, the result is correctly ordered and minimal. + """ + paths = [ + "0001", + "00010001", + "000100010001", + "000100020002", + "000100010002", + "000100020001", + "00020001", + "0002", + "00020002", + "000300010001", + "00030001", + "00030002", + "0004000100030001", + "000400010003", + "000400010004", + ] + filtered_paths = filter_root_paths(paths, skip_sorting=True) + assert filtered_paths == [ + "0001", + "00020001", + "0002", + "000300010001", + "00030001", + "00030002", + "0004000100030001", + "000400010003", + "000400010004", + ] + filtered_paths = filter_root_paths(paths) + assert filtered_paths == [ + "0001", + "0002", + "00030001", + "00030002", + "000400010003", + "000400010004", + ] diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_nest_tree.py b/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_nest_tree.py new file mode 100644 index 00000000..11d2d2f0 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_api_utils_nest_tree.py @@ -0,0 +1,107 @@ +"""Unit tests for the nest_tree utility function.""" + +import pytest + +from core.api.utils import nest_tree + + +def test_api_utils_nest_tree_empty_list(): + """Test that an empty list returns an empty nested structure.""" + # pylint: disable=use-implicit-booleaness-not-comparison + assert nest_tree([], 4) is None + + +def test_api_utils_nest_tree_single_document(): + """Test that a single document is returned as the only root element.""" + documents = [{"id": "1", "path": "0001"}] + expected = {"id": "1", "path": "0001", "children": []} + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_multiple_root_documents(): + """Test that multiple root-level documents are correctly added to the root.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "0002"}, + ] + with pytest.raises( + ValueError, + match="More than one root element detected.", + ): + nest_tree(documents, 4) + + +def test_api_utils_nest_tree_nested_structure(): + """Test that documents are correctly nested based on path levels.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "00010001"}, + {"id": "3", "path": "000100010001"}, + {"id": "4", "path": "00010002"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + { + "id": "2", + "path": "00010001", + "children": [{"id": "3", "path": "000100010001", "children": []}], + }, + {"id": "4", "path": "00010002", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_siblings_at_same_path(): + """ + Test that sibling documents with the same path are correctly grouped under the same parent. + """ + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "00010001"}, + {"id": "3", "path": "00010002"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + {"id": "2", "path": "00010001", "children": []}, + {"id": "3", "path": "00010002", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_decreasing_path_resets_parent(): + """Test that a document at a lower path resets the parent assignment correctly.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "6", "path": "00010001"}, + {"id": "2", "path": "00010002"}, # unordered + {"id": "5", "path": "000100010001"}, + {"id": "3", "path": "000100010002"}, + {"id": "4", "path": "00010003"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + { + "id": "6", + "path": "00010001", + "children": [ + {"id": "5", "path": "000100010001", "children": []}, + {"id": "3", "path": "000100010002", "children": []}, + ], + }, + { + "id": "2", + "path": "00010002", + "children": [], + }, + {"id": "4", "path": "00010003", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_malware_detection.py b/submissions/devoteam/docs/src/backend/core/tests/test_malware_detection.py new file mode 100644 index 00000000..57da7643 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_malware_detection.py @@ -0,0 +1,76 @@ +"""Test malware detection callback.""" + +import random + +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + +import pytest +from lasuite.malware_detection.enums import ReportStatus + +from core.enums import DocumentAttachmentStatus +from core.factories import DocumentFactory +from core.malware_detection import malware_detection_callback + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="safe_file") +def fixture_safe_file(): + """Create a safe file.""" + file_path = "test.txt" + default_storage.save(file_path, ContentFile("test")) + yield file_path + default_storage.delete(file_path) + + +@pytest.fixture(name="unsafe_file") +def fixture_unsafe_file(): + """Create an unsafe file.""" + file_path = "unsafe.txt" + default_storage.save(file_path, ContentFile("test")) + yield file_path + + +def test_malware_detection_callback_safe_status(safe_file): + """Test malware detection callback with safe status.""" + + document = DocumentFactory(attachments=[safe_file]) + + malware_detection_callback( + safe_file, + ReportStatus.SAFE, + error_info={}, + document_id=document.id, + ) + + document.refresh_from_db() + + assert safe_file in document.attachments + assert default_storage.exists(safe_file) + + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + head_resp = s3_client.head_object(Bucket=bucket_name, Key=safe_file) + metadata = head_resp.get("Metadata", {}) + assert metadata["status"] == DocumentAttachmentStatus.READY + + +def test_malware_detection_callback_unsafe_status(unsafe_file): + """Test malware detection callback with unsafe status.""" + + document = DocumentFactory(attachments=[unsafe_file]) + + malware_detection_callback( + unsafe_file, + random.choice( + [status.value for status in ReportStatus if status != ReportStatus.SAFE] + ), + error_info={"error": "test", "error_code": 4001}, + document_id=document.id, + ) + + document.refresh_from_db() + + assert unsafe_file not in document.attachments + assert not default_storage.exists(unsafe_file) diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_models_document_accesses.py b/submissions/devoteam/docs/src/backend/core/tests/test_models_document_accesses.py new file mode 100644 index 00000000..fe0e7c1c --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_models_document_accesses.py @@ -0,0 +1,427 @@ +""" +Unit tests for the DocumentAccess model +""" + +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_document_accesses_str(): + """ + The str representation should include user email, document title and role. + """ + user = factories.UserFactory(email="david.bowman@example.com") + access = factories.UserDocumentAccessFactory( + role="reader", + user=user, + document__title="admins", + ) + assert str(access) == "david.bowman@example.com is reader in document admins" + + +def test_models_document_accesses_unique_user(): + """Document accesses should be unique for a given couple of user and document.""" + access = factories.UserDocumentAccessFactory() + + with pytest.raises( + ValidationError, + match="This user is already in this document.", + ): + factories.UserDocumentAccessFactory(user=access.user, document=access.document) + + +def test_models_document_accesses_several_empty_teams(): + """A document can have several document accesses with an empty team.""" + access = factories.UserDocumentAccessFactory() + factories.UserDocumentAccessFactory(document=access.document) + + +def test_models_document_accesses_unique_team(): + """Document accesses should be unique for a given couple of team and document.""" + access = factories.TeamDocumentAccessFactory() + + with pytest.raises( + ValidationError, + match="This team is already in this document.", + ): + factories.TeamDocumentAccessFactory(team=access.team, document=access.document) + + +def test_models_document_accesses_several_null_users(): + """A document can have several document accesses with a null user.""" + access = factories.TeamDocumentAccessFactory() + factories.TeamDocumentAccessFactory(document=access.document) + + +def test_models_document_accesses_user_and_team_set(): + """User and team can't both be set on a document access.""" + with pytest.raises( + ValidationError, + match="Either user or team must be set, not both.", + ): + factories.UserDocumentAccessFactory(team="my-team") + + +def test_models_document_accesses_user_and_team_empty(): + """User and team can't both be empty on a document access.""" + with pytest.raises( + ValidationError, + match="Either user or team must be set, not both.", + ): + factories.UserDocumentAccessFactory(user=None) + + +# get_abilities + + +def test_models_document_access_get_abilities_anonymous(): + """Check abilities returned for an anonymous user.""" + access = factories.UserDocumentAccessFactory() + abilities = access.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_authenticated(): + """Check abilities returned for an authenticated user.""" + access = factories.UserDocumentAccessFactory() + user = factories.UserFactory() + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +# - for owner + + +def test_models_document_access_get_abilities_for_owner_of_self_allowed(): + """ + Check abilities of self access for the owner of a document when + there is more than one owner left. + """ + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document, role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["administrator", "editor", "reader"], + } + + +def test_models_document_access_get_abilities_for_owner_of_self_last(): + """ + Check abilities of self access for the owner of a document when there is only one owner left. + """ + access = factories.UserDocumentAccessFactory(role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_owner_of_owner(): + """Check abilities of owner access for the owner of a document.""" + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["administrator", "editor", "reader"], + } + + +def test_models_document_access_get_abilities_for_owner_of_administrator(): + """Check abilities of administrator access for the owner of a document.""" + access = factories.UserDocumentAccessFactory(role="administrator") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["owner", "editor", "reader"], + } + + +def test_models_document_access_get_abilities_for_owner_of_editor(): + """Check abilities of editor access for the owner of a document.""" + access = factories.UserDocumentAccessFactory(role="editor") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["owner", "administrator", "reader"], + } + + +def test_models_document_access_get_abilities_for_owner_of_reader(): + """Check abilities of reader access for the owner of a document.""" + access = factories.UserDocumentAccessFactory(role="reader") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["owner", "administrator", "editor"], + } + + +# - for administrator + + +def test_models_document_access_get_abilities_for_administrator_of_owner(): + """Check abilities of owner access for the administrator of a document.""" + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_administrator_of_administrator(): + """Check abilities of administrator access for the administrator of a document.""" + access = factories.UserDocumentAccessFactory(role="administrator") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["editor", "reader"], + } + + +def test_models_document_access_get_abilities_for_administrator_of_editor(): + """Check abilities of editor access for the administrator of a document.""" + access = factories.UserDocumentAccessFactory(role="editor") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["administrator", "reader"], + } + + +def test_models_document_access_get_abilities_for_administrator_of_reader(): + """Check abilities of reader access for the administrator of a document.""" + access = factories.UserDocumentAccessFactory(role="reader") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["administrator", "editor"], + } + + +# - for editor + + +def test_models_document_access_get_abilities_for_editor_of_owner(): + """Check abilities of owner access for the editor of a document.""" + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="editor" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_editor_of_administrator(): + """Check abilities of administrator access for the editor of a document.""" + access = factories.UserDocumentAccessFactory(role="administrator") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="editor" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_editor_of_editor_user( + django_assert_num_queries, +): + """Check abilities of editor access for the editor of a document.""" + access = factories.UserDocumentAccessFactory(role="editor") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="editor" + ).user + + with django_assert_num_queries(1): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +# - for reader + + +def test_models_document_access_get_abilities_for_reader_of_owner(): + """Check abilities of owner access for the reader of a document.""" + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="reader" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_reader_of_administrator(): + """Check abilities of administrator access for the reader of a document.""" + access = factories.UserDocumentAccessFactory(role="administrator") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="reader" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_reader_of_reader_user( + django_assert_num_queries, +): + """Check abilities of reader access for the reader of a document.""" + access = factories.UserDocumentAccessFactory(role="reader") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="reader" + ).user + + with django_assert_num_queries(1): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset, e.g., with a query annotation.""" + access = factories.UserDocumentAccessFactory(role="reader") + user = factories.UserDocumentAccessFactory( + document=access.document, role="reader" + ).user + access.user_roles = ["reader"] + + with django_assert_num_queries(0): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +@pytest.mark.parametrize("role", models.RoleChoices) +def test_models_document_access_get_abilities_retrieve_own_access(role): + """Check abilities of self access for the owner of a document.""" + access = factories.UserDocumentAccessFactory(role=role) + abilities = access.get_abilities(access.user) + assert abilities["retrieve"] is True diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_models_documents.py b/submissions/devoteam/docs/src/backend/core/tests/test_models_documents.py new file mode 100644 index 00000000..01d5181e --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_models_documents.py @@ -0,0 +1,1359 @@ +""" +Unit tests for the Document model +""" +# pylint: disable=too-many-lines + +import random +import smtplib +from logging import Logger +from unittest import mock + +from django.contrib.auth.models import AnonymousUser +from django.core import mail +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.core.files.storage import default_storage +from django.test.utils import override_settings +from django.utils import timezone + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_documents_str(): + """The str representation should be the title of the document.""" + document = factories.DocumentFactory(title="admins") + assert str(document) == "admins" + + +def test_models_documents_id_unique(): + """The "id" field should be unique.""" + document = factories.DocumentFactory() + with pytest.raises(ValidationError, match="Document with this Id already exists."): + factories.DocumentFactory(id=document.id) + + +def test_models_documents_creator_required(): + """No field should be required on the Document model.""" + models.Document.add_root() + + +def test_models_documents_title_null(): + """The "title" field can be null.""" + document = models.Document.add_root(title=None, creator=factories.UserFactory()) + assert document.title is None + + +def test_models_documents_title_empty(): + """The "title" field can be empty.""" + document = models.Document.add_root(title="", creator=factories.UserFactory()) + assert document.title == "" + + +def test_models_documents_title_max_length(): + """The "title" field should be 100 characters maximum.""" + factories.DocumentFactory(title="a" * 255) + with pytest.raises( + ValidationError, + match=r"Ensure this value has at most 255 characters \(it has 256\)\.", + ): + factories.DocumentFactory(title="a" * 256) + + +def test_models_documents_file_key(): + """The file key should be built from the instance uuid.""" + document = factories.DocumentFactory(id="9531a5f1-42b1-496c-b3f4-1c09ed139b3c") + assert document.file_key == "9531a5f1-42b1-496c-b3f4-1c09ed139b3c/file" + + +def test_models_documents_tree_alphabet(): + """Test the creation of documents with treebeard methods.""" + models.Document.load_bulk( + [ + { + "data": { + "title": f"document-{i}", + } + } + for i in range(len(models.Document.alphabet) * 2) + ] + ) + + assert models.Document.objects.count() == 124 + + +@pytest.mark.parametrize("depth", range(5)) +def test_models_documents_soft_delete(depth): + """Trying to delete a document that is already deleted or is a descendant of + a deleted document should raise an error. + """ + documents = [] + for i in range(depth + 1): + documents.append( + factories.DocumentFactory() + if i == 0 + else factories.DocumentFactory(parent=documents[-1]) + ) + assert models.Document.objects.count() == depth + 1 + + # Delete any one of the documents... + deleted_document = random.choice(documents) + deleted_document.soft_delete() + + with pytest.raises(RuntimeError): + documents[-1].soft_delete() + + assert deleted_document.deleted_at is not None + assert deleted_document.ancestors_deleted_at == deleted_document.deleted_at + + descendants = deleted_document.get_descendants() + for child in descendants: + assert child.deleted_at is None + assert child.ancestors_deleted_at is not None + assert child.ancestors_deleted_at == deleted_document.deleted_at + + ancestors = deleted_document.get_ancestors() + for parent in ancestors: + assert parent.deleted_at is None + assert parent.ancestors_deleted_at is None + + assert len(ancestors) + len(descendants) == depth + + +# get_abilities + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +@pytest.mark.parametrize( + "is_authenticated,reach,role", + [ + (True, "restricted", "reader"), + (True, "restricted", "editor"), + (False, "restricted", "reader"), + (False, "restricted", "editor"), + (False, "authenticated", "reader"), + (False, "authenticated", "editor"), + ], +) +def test_models_documents_get_abilities_forbidden( + is_authenticated, reach, role, django_assert_num_queries +): + """ + Check abilities returned for a document giving insufficient roles to link holders + i.e anonymous users or authenticated users who have no specific role on the document. + """ + document = factories.DocumentFactory(link_reach=reach, link_role=role) + user = factories.UserFactory() if is_authenticated else AnonymousUser() + expected_abilities = { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": False, + "children_create": False, + "children_list": False, + "collaboration_auth": False, + "descendants": False, + "cors_proxy": False, + "destroy": False, + "duplicate": False, + "favorite": False, + "invite_owner": False, + "media_auth": False, + "media_check": False, + "move": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "partial_update": False, + "restore": False, + "retrieve": False, + "tree": False, + "update": False, + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + } + nb_queries = 1 if is_authenticated else 0 + with django_assert_num_queries(nb_queries): + assert document.get_abilities(user) == expected_abilities + document.soft_delete() + document.refresh_from_db() + assert document.get_abilities(user) == expected_abilities + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +@pytest.mark.parametrize( + "is_authenticated,reach", + [ + (True, "public"), + (False, "public"), + (True, "authenticated"), + ], +) +def test_models_documents_get_abilities_reader( + is_authenticated, reach, django_assert_num_queries +): + """ + Check abilities returned for a document giving reader role to link holders + i.e anonymous users or authenticated users who have no specific role on the document. + """ + document = factories.DocumentFactory(link_reach=reach, link_role="reader") + user = factories.UserFactory() if is_authenticated else AnonymousUser() + expected_abilities = { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": False, + "children_create": False, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": is_authenticated, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": False, + "restore": False, + "retrieve": True, + "tree": True, + "update": False, + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + } + nb_queries = 1 if is_authenticated else 0 + with django_assert_num_queries(nb_queries): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) + + +@pytest.mark.parametrize( + "is_authenticated,reach", + [ + (True, "public"), + (False, "public"), + (True, "authenticated"), + ], +) +def test_models_documents_get_abilities_editor( + is_authenticated, reach, django_assert_num_queries +): + """ + Check abilities returned for a document giving editor role to link holders + i.e anonymous users or authenticated users who have no specific role on the document. + """ + document = factories.DocumentFactory(link_reach=reach, link_role="editor") + user = factories.UserFactory() if is_authenticated else AnonymousUser() + expected_abilities = { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": is_authenticated, + "ai_translate": is_authenticated, + "attachment_upload": True, + "children_create": is_authenticated, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": is_authenticated, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": True, + "restore": False, + "retrieve": True, + "tree": True, + "update": True, + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + } + nb_queries = 1 if is_authenticated else 0 + with django_assert_num_queries(nb_queries): + assert document.get_abilities(user) == expected_abilities + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +def test_models_documents_get_abilities_owner(django_assert_num_queries): + """Check abilities returned for the owner of a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "owner")]) + expected_abilities = { + "accesses_manage": True, + "accesses_view": True, + "ai_transform": True, + "ai_translate": True, + "attachment_upload": True, + "children_create": True, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": True, + "duplicate": True, + "favorite": True, + "invite_owner": True, + "link_configuration": True, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": True, + "partial_update": True, + "restore": True, + "retrieve": True, + "tree": True, + "update": True, + "versions_destroy": True, + "versions_list": True, + "versions_retrieve": True, + } + with django_assert_num_queries(1): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + expected_abilities["move"] = False + assert document.get_abilities(user) == expected_abilities + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +def test_models_documents_get_abilities_administrator(django_assert_num_queries): + """Check abilities returned for the administrator of a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "administrator")]) + expected_abilities = { + "accesses_manage": True, + "accesses_view": True, + "ai_transform": True, + "ai_translate": True, + "attachment_upload": True, + "children_create": True, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": True, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": True, + "partial_update": True, + "restore": False, + "retrieve": True, + "tree": True, + "update": True, + "versions_destroy": True, + "versions_list": True, + "versions_retrieve": True, + } + with django_assert_num_queries(1): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +def test_models_documents_get_abilities_editor_user(django_assert_num_queries): + """Check abilities returned for the editor of a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "editor")]) + expected_abilities = { + "accesses_manage": False, + "accesses_view": True, + "ai_transform": True, + "ai_translate": True, + "attachment_upload": True, + "children_create": True, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": True, + "restore": False, + "retrieve": True, + "tree": True, + "update": True, + "versions_destroy": False, + "versions_list": True, + "versions_retrieve": True, + } + with django_assert_num_queries(1): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) + + +@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"]) +def test_models_documents_get_abilities_reader_user( + ai_access_setting, django_assert_num_queries +): + """Check abilities returned for the reader of a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "reader")]) + + access_from_link = ( + document.link_reach != "restricted" and document.link_role == "editor" + ) + + expected_abilities = { + "accesses_manage": False, + "accesses_view": True, + # If you get your editor rights from the link role and not your access role + # You should not access AI if it's restricted to users with specific access + "ai_transform": access_from_link and ai_access_setting != "restricted", + "ai_translate": access_from_link and ai_access_setting != "restricted", + "attachment_upload": access_from_link, + "children_create": access_from_link, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": access_from_link, + "restore": False, + "retrieve": True, + "tree": True, + "update": access_from_link, + "versions_destroy": False, + "versions_list": True, + "versions_retrieve": True, + } + + with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting): + with django_assert_num_queries(1): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) + + +def test_models_documents_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset e.g. with query annotation.""" + access = factories.UserDocumentAccessFactory( + role="reader", document__link_role="reader" + ) + access.document.user_roles = ["reader"] + + with django_assert_num_queries(0): + abilities = access.document.get_abilities(access.user) + + assert abilities == { + "accesses_manage": False, + "accesses_view": True, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": False, + "children_create": False, + "children_list": True, + "collaboration_auth": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": False, + "restore": False, + "retrieve": True, + "tree": True, + "update": False, + "versions_destroy": False, + "versions_list": True, + "versions_retrieve": True, + } + + +@override_settings(AI_ALLOW_REACH_FROM="public") +@pytest.mark.parametrize( + "is_authenticated,reach", + [ + (True, "public"), + (False, "public"), + (True, "authenticated"), + ], +) +def test_models_document_get_abilities_ai_access_authenticated(is_authenticated, reach): + """Validate AI abilities when AI is available to any anonymous user with editor rights.""" + user = factories.UserFactory() if is_authenticated else AnonymousUser() + document = factories.DocumentFactory(link_reach=reach, link_role="editor") + + abilities = document.get_abilities(user) + assert abilities["ai_transform"] is True + assert abilities["ai_translate"] is True + + +@override_settings(AI_ALLOW_REACH_FROM="authenticated") +@pytest.mark.parametrize( + "is_authenticated,reach", + [ + (True, "public"), + (False, "public"), + (True, "authenticated"), + ], +) +def test_models_document_get_abilities_ai_access_public(is_authenticated, reach): + """Validate AI abilities when AI is available only to authenticated users with editor rights.""" + user = factories.UserFactory() if is_authenticated else AnonymousUser() + document = factories.DocumentFactory(link_reach=reach, link_role="editor") + + abilities = document.get_abilities(user) + assert abilities["ai_transform"] == is_authenticated + assert abilities["ai_translate"] == is_authenticated + + +def test_models_documents_get_versions_slice_pagination(settings): + """ + The "get_versions_slice" method should allow navigating all versions of + the document with pagination. + """ + settings.DOCUMENT_VERSIONS_PAGE_SIZE = 4 + + # Create a document with 7 versions + document = factories.DocumentFactory() + for i in range(6): + document.content = f"bar{i:d}" + document.save() + + # Add a document version not related to the first document + factories.DocumentFactory() + + # - Get default max versions + response = document.get_versions_slice() + assert response["is_truncated"] is True + assert len(response["versions"]) == 4 + assert response["next_version_id_marker"] != "" + + expected_keys = ["etag", "is_latest", "last_modified", "version_id"] + for i in range(4): + assert list(response["versions"][i].keys()) == expected_keys + + # - Get page 2 + response = document.get_versions_slice( + from_version_id=response["next_version_id_marker"] + ) + assert response["is_truncated"] is False + assert len(response["versions"]) == 2 + assert response["next_version_id_marker"] == "" + + # - Get custom max versions + response = document.get_versions_slice(page_size=2) + assert response["is_truncated"] is True + assert len(response["versions"]) == 2 + assert response["next_version_id_marker"] != "" + + +def test_models_documents_get_versions_slice_min_datetime(): + """ + The "get_versions_slice" method should filter out versions anterior to + the from_datetime passed in argument and the current version. + """ + document = factories.DocumentFactory() + from_dt = [] + for i in range(6): + from_dt.append(timezone.now()) + document.content = f"bar{i:d}" + document.save() + + response = document.get_versions_slice(min_datetime=from_dt[2]) + + assert len(response["versions"]) == 3 + for version in response["versions"]: + assert version["last_modified"] > from_dt[2] + + response = document.get_versions_slice(min_datetime=from_dt[4]) + + assert len(response["versions"]) == 1 + assert response["versions"][0]["last_modified"] > from_dt[4] + + +def test_models_documents_version_duplicate(): + """A new version should be created in object storage only if the content has changed.""" + document = factories.DocumentFactory() + + file_key = str(document.pk) + response = default_storage.connection.meta.client.list_object_versions( + Bucket=default_storage.bucket_name, Prefix=file_key + ) + assert len(response["Versions"]) == 1 + + # Save again with the same content + document.save() + + response = default_storage.connection.meta.client.list_object_versions( + Bucket=default_storage.bucket_name, Prefix=file_key + ) + assert len(response["Versions"]) == 1 + + # Save modified content + document.content = "new content" + document.save() + + response = default_storage.connection.meta.client.list_object_versions( + Bucket=default_storage.bucket_name, Prefix=file_key + ) + assert len(response["Versions"]) == 2 + + +def test_models_documents__email_invitation__success(): + """ + The email invitation is sent successfully. + """ + document = factories.DocumentFactory() + + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com") + document.send_invitation_email( + "guest@example.com", models.RoleChoices.EDITOR, sender, "en" + ) + + # pylint: disable-next=no-member + assert len(mail.outbox) == 1 + + # pylint: disable-next=no-member + email = mail.outbox[0] + + assert email.to == ["guest@example.com"] + email_content = " ".join(email.body.split()) + + assert ( + f"Test Sender (sender@example.com) invited you with the role "editor" " + f"on the following document: {document.title}" in email_content + ) + assert f"docs/{document.id}/" in email_content + + +def test_models_documents__email_invitation__success_empty_title(): + """ + The email invitation is sent successfully. + """ + document = factories.DocumentFactory(title=None) + + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com") + document.send_invitation_email( + "guest@example.com", models.RoleChoices.EDITOR, sender, "en" + ) + + # pylint: disable-next=no-member + assert len(mail.outbox) == 1 + + # pylint: disable-next=no-member + email = mail.outbox[0] + + assert email.to == ["guest@example.com"] + email_content = " ".join(email.body.split()) + + assert "Test sender shared a document with you!" in email.subject + assert ( + "Test Sender (sender@example.com) invited you with the role "editor" " + "on the following document: Untitled Document" in email_content + ) + assert f"docs/{document.id}/" in email_content + + +def test_models_documents__email_invitation__success_fr(): + """ + The email invitation is sent successfully in french. + """ + document = factories.DocumentFactory() + + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + sender = factories.UserFactory( + full_name="Test Sender2", email="sender2@example.com" + ) + document.send_invitation_email( + "guest2@example.com", + models.RoleChoices.OWNER, + sender, + "fr-fr", + ) + + # pylint: disable-next=no-member + assert len(mail.outbox) == 1 + + # pylint: disable-next=no-member + email = mail.outbox[0] + + assert email.to == ["guest2@example.com"] + email_content = " ".join(email.body.split()) + + assert ( + f"Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" " + f"sur le document suivant : {document.title}" in email_content + ) + assert f"docs/{document.id}/" in email_content + + +@mock.patch( + "core.models.send_mail", + side_effect=smtplib.SMTPException("Error SMTPException"), +) +@mock.patch.object(Logger, "error") +def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail): + """Check mail behavior when an SMTP error occurs when sent an email invitation.""" + document = factories.DocumentFactory() + + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + sender = factories.UserFactory() + document.send_invitation_email( + "guest3@example.com", + models.RoleChoices.ADMIN, + sender, + "en", + ) + + # No email has been sent + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + # Logger should be called + mock_logger.assert_called_once() + + ( + _, + emails, + exception, + ) = mock_logger.call_args.args + + assert emails == ["guest3@example.com"] + assert isinstance(exception, smtplib.SMTPException) + + +# Document number of accesses + + +def test_models_documents_nb_accesses_cache_is_set_and_retrieved_ancestors( + django_assert_num_queries, +): + """Test that nb_accesses is cached when calling nb_accesses_ancestors.""" + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + key = f"document_{document.id!s}_nb_accesses" + nb_accesses_parent = random.randint(1, 4) + factories.UserDocumentAccessFactory.create_batch( + nb_accesses_parent, document=parent + ) + nb_accesses_direct = random.randint(1, 4) + factories.UserDocumentAccessFactory.create_batch( + nb_accesses_direct, document=document + ) + factories.UserDocumentAccessFactory() # An unrelated access should not be counted + + # Initially, the nb_accesses should not be cached + assert cache.get(key) is None + + # Compute the nb_accesses for the first time (this should set the cache) + nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct + with django_assert_num_queries(2): + assert document.nb_accesses_ancestors == nb_accesses_ancestors + + # Ensure that the nb_accesses is now cached + with django_assert_num_queries(0): + assert document.nb_accesses_ancestors == nb_accesses_ancestors + assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors) + + # The cache value should be invalidated when a document access is created + models.DocumentAccess.objects.create( + document=document, user=factories.UserFactory(), role="reader" + ) + assert cache.get(key) is None # Cache should be invalidated + with django_assert_num_queries(2): + assert document.nb_accesses_ancestors == nb_accesses_ancestors + 1 + assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1) + + +def test_models_documents_nb_accesses_cache_is_set_and_retrieved_direct( + django_assert_num_queries, +): + """Test that nb_accesses is cached when calling nb_accesses_direct.""" + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + key = f"document_{document.id!s}_nb_accesses" + nb_accesses_parent = random.randint(1, 4) + factories.UserDocumentAccessFactory.create_batch( + nb_accesses_parent, document=parent + ) + nb_accesses_direct = random.randint(1, 4) + factories.UserDocumentAccessFactory.create_batch( + nb_accesses_direct, document=document + ) + factories.UserDocumentAccessFactory() # An unrelated access should not be counted + + # Initially, the nb_accesses should not be cached + assert cache.get(key) is None + + # Compute the nb_accesses for the first time (this should set the cache) + nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct + with django_assert_num_queries(2): + assert document.nb_accesses_direct == nb_accesses_direct + + # Ensure that the nb_accesses is now cached + with django_assert_num_queries(0): + assert document.nb_accesses_direct == nb_accesses_direct + assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors) + + # The cache value should be invalidated when a document access is created + models.DocumentAccess.objects.create( + document=document, user=factories.UserFactory(), role="reader" + ) + assert cache.get(key) is None # Cache should be invalidated + with django_assert_num_queries(2): + assert document.nb_accesses_direct == nb_accesses_direct + 1 + assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1) + + +@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"]) +def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal( + field, + django_assert_num_queries, +): + """Test that the cache is invalidated when a document access is deleted.""" + document = factories.DocumentFactory() + key = f"document_{document.id!s}_nb_accesses" + access = factories.UserDocumentAccessFactory(document=document) + + # Initially, the nb_accesses should be cached + assert getattr(document, field) == 1 + assert cache.get(key) == (1, 1) + + # Remove the access and check if cache is invalidated + access.delete() + assert cache.get(key) is None # Cache should be invalidated + + # Recompute the nb_accesses (this should trigger a cache set) + with django_assert_num_queries(2): + new_nb_accesses = getattr(document, field) + assert new_nb_accesses == 0 + assert cache.get(key) == (0, 0) # Cache should now contain the new value + + +@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"]) +def test_models_documents_nb_accesses_cache_is_invalidated_on_document_soft_delete_restore( + field, + django_assert_num_queries, +): + """Test that the cache is invalidated when a document access is deleted.""" + document = factories.DocumentFactory() + key = f"document_{document.id!s}_nb_accesses" + factories.UserDocumentAccessFactory(document=document) + + # Initially, the nb_accesses should be cached + assert getattr(document, field) == 1 + assert cache.get(key) == (1, 1) + + # Soft delete the document and check if cache is invalidated + document.soft_delete() + assert cache.get(key) is None # Cache should be invalidated + + # Recompute the nb_accesses (this should trigger a cache set) + with django_assert_num_queries(2): + new_nb_accesses = getattr(document, field) + assert new_nb_accesses == (1 if field == "nb_accesses_direct" else 0) + assert cache.get(key) == (1, 0) # Cache should now contain the new value + + document.restore() + + # Recompute the nb_accesses (this should trigger a cache set) + with django_assert_num_queries(2): + new_nb_accesses = getattr(document, field) + assert new_nb_accesses == 1 + assert cache.get(key) == (1, 1) # Cache should now contain the new value + + +def test_models_documents_numchild_deleted_from_instance(): + """the "numchild" field should not include documents deleted from the instance.""" + document = factories.DocumentFactory() + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + assert document.numchild == 2 + + child1.delete() + + document.refresh_from_db() + assert document.numchild == 1 + + +def test_models_documents_numchild_deleted_from_queryset(): + """the "numchild" field should not include documents deleted from a queryset.""" + document = factories.DocumentFactory() + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + assert document.numchild == 2 + + models.Document.objects.filter(pk=child1.pk).delete() + + document.refresh_from_db() + assert document.numchild == 1 + + +def test_models_documents_numchild_soft_deleted_and_restore(): + """the "numchild" field should not include soft deleted documents.""" + document = factories.DocumentFactory() + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + + assert document.numchild == 2 + + child1.soft_delete() + + document.refresh_from_db() + assert document.numchild == 1 + + child1.restore() + + document.refresh_from_db() + assert document.numchild == 2 + + +def test_models_documents_soft_delete_tempering_with_instance(): + """ + Soft deleting should fail if the document is already deleted in database even though the + instance "deleted_at" attributes where tempered with. + """ + document = factories.DocumentFactory() + document.soft_delete() + + document.deleted_at = None + document.ancestors_deleted_at = None + with pytest.raises( + RuntimeError, match="This document is already deleted or has deleted ancestors." + ): + document.soft_delete() + + +def test_models_documents_restore_tempering_with_instance(): + """ + Soft deleting should fail if the document is already deleted in database even though the + instance "deleted_at" attributes where tempered with. + """ + document = factories.DocumentFactory() + + if random.choice([False, True]): + document.deleted_at = timezone.now() + else: + document.ancestors_deleted_at = timezone.now() + + with pytest.raises(RuntimeError, match="This document is not deleted."): + document.restore() + + +def test_models_documents_restore(django_assert_num_queries): + """The restore method should restore a soft-deleted document.""" + document = factories.DocumentFactory() + document.soft_delete() + document.refresh_from_db() + assert document.deleted_at is not None + assert document.ancestors_deleted_at == document.deleted_at + + with django_assert_num_queries(8): + document.restore() + document.refresh_from_db() + assert document.deleted_at is None + assert document.ancestors_deleted_at == document.deleted_at + + +def test_models_documents_restore_complex(django_assert_num_queries): + """The restore method should restore a soft-deleted document and its ancestors.""" + grand_parent = factories.DocumentFactory() + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + + child1 = factories.DocumentFactory(parent=document) + child2 = factories.DocumentFactory(parent=document) + + # Soft delete first the document + document.soft_delete() + document.refresh_from_db() + child1.refresh_from_db() + child2.refresh_from_db() + assert document.deleted_at is not None + assert document.ancestors_deleted_at == document.deleted_at + assert child1.ancestors_deleted_at == document.deleted_at + assert child2.ancestors_deleted_at == document.deleted_at + + # Soft delete the grand parent + grand_parent.soft_delete() + grand_parent.refresh_from_db() + parent.refresh_from_db() + assert grand_parent.deleted_at is not None + assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at + assert parent.ancestors_deleted_at == grand_parent.deleted_at + # item, child1 and child2 should not be affected + document.refresh_from_db() + child1.refresh_from_db() + child2.refresh_from_db() + assert document.deleted_at is not None + assert document.ancestors_deleted_at == document.deleted_at + assert child1.ancestors_deleted_at == document.deleted_at + assert child2.ancestors_deleted_at == document.deleted_at + + # Restore the item + with django_assert_num_queries(11): + document.restore() + document.refresh_from_db() + child1.refresh_from_db() + child2.refresh_from_db() + grand_parent.refresh_from_db() + assert document.deleted_at is None + assert document.ancestors_deleted_at == grand_parent.deleted_at + # child 1 and child 2 should now have the same ancestors_deleted_at as the grand parent + assert child1.ancestors_deleted_at == grand_parent.deleted_at + assert child2.ancestors_deleted_at == grand_parent.deleted_at + + +def test_models_documents_restore_complex_bis(django_assert_num_queries): + """The restore method should restore a soft-deleted item and its ancestors.""" + grand_parent = factories.DocumentFactory() + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + + child1 = factories.DocumentFactory(parent=document) + child2 = factories.DocumentFactory(parent=document) + + # Soft delete first the document + document.soft_delete() + document.refresh_from_db() + child1.refresh_from_db() + child2.refresh_from_db() + assert document.deleted_at is not None + assert document.ancestors_deleted_at == document.deleted_at + assert child1.ancestors_deleted_at == document.deleted_at + assert child2.ancestors_deleted_at == document.deleted_at + + # Soft delete the grand parent + grand_parent.soft_delete() + grand_parent.refresh_from_db() + parent.refresh_from_db() + assert grand_parent.deleted_at is not None + assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at + assert parent.ancestors_deleted_at == grand_parent.deleted_at + # item, child1 and child2 should not be affected + document.refresh_from_db() + child1.refresh_from_db() + child2.refresh_from_db() + assert document.deleted_at is not None + assert document.ancestors_deleted_at == document.deleted_at + assert child1.ancestors_deleted_at == document.deleted_at + assert child2.ancestors_deleted_at == document.deleted_at + + # Restoring the grand parent should not restore the document + # as it was deleted before the grand parent + with django_assert_num_queries(9): + grand_parent.restore() + + grand_parent.refresh_from_db() + parent.refresh_from_db() + document.refresh_from_db() + child1.refresh_from_db() + child2.refresh_from_db() + assert grand_parent.deleted_at is None + assert grand_parent.ancestors_deleted_at is None + assert parent.deleted_at is None + assert parent.ancestors_deleted_at is None + assert document.deleted_at is not None + assert document.ancestors_deleted_at == document.deleted_at + assert child1.ancestors_deleted_at == document.deleted_at + assert child2.ancestors_deleted_at == document.deleted_at + + +@pytest.mark.parametrize( + "ancestors_links, select_options", + [ + # One ancestor + ( + [{"link_reach": "public", "link_role": "reader"}], + { + "restricted": ["editor"], + "authenticated": ["editor"], + "public": ["reader", "editor"], + }, + ), + ([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}), + ( + [{"link_reach": "authenticated", "link_role": "reader"}], + { + "restricted": ["editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + ( + [{"link_reach": "authenticated", "link_role": "editor"}], + {"authenticated": ["editor"], "public": ["reader", "editor"]}, + ), + ( + [{"link_reach": "restricted", "link_role": "reader"}], + { + "restricted": ["reader", "editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + ( + [{"link_reach": "restricted", "link_role": "editor"}], + { + "restricted": ["editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + # Multiple ancestors with different roles + ( + [ + {"link_reach": "public", "link_role": "reader"}, + {"link_reach": "public", "link_role": "editor"}, + ], + {"public": ["editor"]}, + ), + ( + [ + {"link_reach": "authenticated", "link_role": "reader"}, + {"link_reach": "authenticated", "link_role": "editor"}, + ], + {"authenticated": ["editor"], "public": ["reader", "editor"]}, + ), + ( + [ + {"link_reach": "restricted", "link_role": "reader"}, + {"link_reach": "restricted", "link_role": "editor"}, + ], + { + "restricted": ["editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + # Multiple ancestors with different reaches + ( + [ + {"link_reach": "authenticated", "link_role": "reader"}, + {"link_reach": "public", "link_role": "reader"}, + ], + { + "restricted": ["editor"], + "authenticated": ["editor"], + "public": ["reader", "editor"], + }, + ), + ( + [ + {"link_reach": "restricted", "link_role": "reader"}, + {"link_reach": "authenticated", "link_role": "reader"}, + {"link_reach": "public", "link_role": "reader"}, + ], + { + "restricted": ["editor"], + "authenticated": ["editor"], + "public": ["reader", "editor"], + }, + ), + # Multiple ancestors with mixed reaches and roles + ( + [ + {"link_reach": "authenticated", "link_role": "editor"}, + {"link_reach": "public", "link_role": "reader"}, + ], + {"authenticated": ["editor"], "public": ["reader", "editor"]}, + ), + ( + [ + {"link_reach": "authenticated", "link_role": "reader"}, + {"link_reach": "public", "link_role": "editor"}, + ], + {"public": ["editor"]}, + ), + ( + [ + {"link_reach": "restricted", "link_role": "editor"}, + {"link_reach": "authenticated", "link_role": "reader"}, + ], + { + "restricted": ["editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + ( + [ + {"link_reach": "restricted", "link_role": "reader"}, + {"link_reach": "authenticated", "link_role": "editor"}, + ], + {"authenticated": ["editor"], "public": ["reader", "editor"]}, + ), + # No ancestors (edge case) + ( + [], + { + "public": ["reader", "editor"], + "authenticated": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + ), + ], +) +def test_models_documents_get_select_options(ancestors_links, select_options): + """Validate that the "get_select_options" method operates as expected.""" + assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options + + +def test_models_documents_compute_ancestors_links_no_highest_readable(): + """Test the compute_ancestors_links method.""" + document = factories.DocumentFactory(link_reach="public") + assert document.compute_ancestors_links(user=AnonymousUser()) == [] + + +def test_models_documents_compute_ancestors_links_highest_readable( + django_assert_num_queries, +): + """Test the compute_ancestors_links method.""" + user = factories.UserFactory() + other_user = factories.UserFactory() + root = factories.DocumentFactory( + link_reach="restricted", link_role="reader", users=[user] + ) + + factories.DocumentFactory( + parent=root, link_reach="public", link_role="reader", users=[user] + ) + child2 = factories.DocumentFactory( + parent=root, + link_reach="authenticated", + link_role="editor", + users=[user, other_user], + ) + child3 = factories.DocumentFactory( + parent=child2, + link_reach="authenticated", + link_role="reader", + users=[user, other_user], + ) + + with django_assert_num_queries(2): + assert child3.compute_ancestors_links(user=user) == [ + {"link_reach": root.link_reach, "link_role": root.link_role}, + {"link_reach": child2.link_reach, "link_role": child2.link_role}, + ] + + with django_assert_num_queries(2): + assert child3.compute_ancestors_links(user=other_user) == [ + {"link_reach": child2.link_reach, "link_role": child2.link_role}, + ] diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_models_invitations.py b/submissions/devoteam/docs/src/backend/core/tests/test_models_invitations.py new file mode 100644 index 00000000..4bd538a2 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_models_invitations.py @@ -0,0 +1,271 @@ +""" +Unit tests for the Invitation model +""" + +from datetime import timedelta +from unittest import mock + +from django.contrib.auth.models import AnonymousUser +from django.core import exceptions +from django.utils import timezone + +import pytest +from faker import Faker +from freezegun import freeze_time + +from core import factories, models +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +fake = Faker() + + +def test_models_invitations_email_no_empty_mail(): + """The "email" field should not be empty.""" + with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"): + factories.InvitationFactory(email="") + + +def test_models_invitations_email_no_null_mail(): + """The "email" field is required.""" + with pytest.raises(exceptions.ValidationError, match="This field cannot be null"): + factories.InvitationFactory(email=None) + + +def test_models_invitations_document_required(): + """The "document" field is required.""" + with pytest.raises(exceptions.ValidationError, match="This field cannot be null"): + factories.InvitationFactory(document=None) + + +def test_models_invitations_document_should_be_document_instance(): + """The "document" field should be a document instance.""" + with pytest.raises( + ValueError, match='Invitation.document" must be a "Document" instance' + ): + factories.InvitationFactory(document="ee") + + +def test_models_invitations_role_required(): + """The "role" field is required.""" + with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"): + factories.InvitationFactory(role="") + + +def test_models_invitations_role_among_choices(): + """The "role" field should be a valid choice.""" + with pytest.raises( + exceptions.ValidationError, match="Value 'boss' is not a valid choice" + ): + factories.InvitationFactory(role="boss") + + +def test_models_invitations_is_expired(): + """ + The 'is_expired' property should return False until validity duration + is exceeded and True afterwards. + """ + expired_invitation = factories.InvitationFactory() + assert expired_invitation.is_expired is False + + not_late = timezone.now() + timedelta(seconds=604799) + with mock.patch("django.utils.timezone.now", return_value=not_late): + assert expired_invitation.is_expired is False + + too_late = timezone.now() + timedelta(seconds=604800) # 7 days + with mock.patch("django.utils.timezone.now", return_value=too_late): + assert expired_invitation.is_expired is True + + +def test_models_invitationd_new_userd_convert_invitations_to_accesses(): + """ + Upon creating a new user, invitations linked to the email + should be converted to accesses and then deleted. + """ + # Two invitations to the same mail but to different documents + invitation_to_document1 = factories.InvitationFactory() + invitation_to_document2 = factories.InvitationFactory( + email=invitation_to_document1.email + ) + + other_invitation = factories.InvitationFactory( + document=invitation_to_document2.document + ) # another person invited to document2 + + new_user = factories.UserFactory(email=invitation_to_document1.email) + + # The invitation regarding + assert models.DocumentAccess.objects.filter( + document=invitation_to_document1.document, user=new_user + ).exists() + assert models.DocumentAccess.objects.filter( + document=invitation_to_document2.document, user=new_user + ).exists() + assert not models.Invitation.objects.filter( + document=invitation_to_document1.document, email=invitation_to_document1.email + ).exists() # invitation "consumed" + assert not models.Invitation.objects.filter( + document=invitation_to_document2.document, email=invitation_to_document2.email + ).exists() # invitation "consumed" + assert models.Invitation.objects.filter( + document=invitation_to_document2.document, email=other_invitation.email + ).exists() # the other invitation remains + + +def test_models_invitationd_new_user_filter_expired_invitations(): + """ + Upon creating a new identity, valid invitations should be converted into accesses + and expired invitations should remain unchanged. + """ + document = factories.DocumentFactory() + with freeze_time("2020-01-01"): + expired_invitation = factories.InvitationFactory(document=document) + user_email = expired_invitation.email + valid_invitation = factories.InvitationFactory(email=user_email) + + new_user = factories.UserFactory(email=user_email) + + # valid invitation should have granted access to the related document + assert models.DocumentAccess.objects.filter( + document=valid_invitation.document, user=new_user + ).exists() + assert not models.Invitation.objects.filter( + document=valid_invitation.document, email=user_email + ).exists() + + # expired invitation should not have been consumed + assert not models.DocumentAccess.objects.filter( + document=expired_invitation.document, user=new_user + ).exists() + assert models.Invitation.objects.filter( + document=expired_invitation.document, email=user_email + ).exists() + + +@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)]) +def test_models_invitationd_new_userd_user_creation_constant_num_queries( + django_assert_num_queries, num_invitations, num_queries +): + """ + The number of queries executed during user creation should not be proportional + to the number of invitations being processed. + """ + user_email = fake.email() + + if num_invitations != 0: + factories.InvitationFactory.create_batch(num_invitations, email=user_email) + + # with no invitation, we skip an "if", resulting in 8 requests + # otherwise, we should have 11 queries with any number of invitations + with django_assert_num_queries(num_queries): + models.User.objects.create(email=user_email, password="!") + + +# get_abilities + + +def test_models_document_invitations_get_abilities_anonymous(): + """Check abilities returned for an anonymous user.""" + access = factories.InvitationFactory() + abilities = access.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": False, + "partial_update": False, + "update": False, + } + + +def test_models_document_invitations_get_abilities_authenticated(): + """Check abilities returned for an authenticated user.""" + access = factories.InvitationFactory() + user = factories.UserFactory() + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "partial_update": False, + "update": False, + } + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("role", ["administrator", "owner"]) +def test_models_document_invitations_get_abilities_privileged_member( + role, via, mock_user_teams +): + """Check abilities for a document member with a privileged role.""" + + user = factories.UserFactory() + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + factories.UserDocumentAccessFactory(document=document) # another one + + invitation = factories.InvitationFactory(document=document) + abilities = invitation.get_abilities(user) + + assert abilities == { + "destroy": True, + "retrieve": True, + "partial_update": True, + "update": True, + } + + +@pytest.mark.parametrize("via", VIA) +def test_models_document_invitations_get_abilities_reader(via, mock_user_teams): + """Check abilities for a document reader with 'reader' role.""" + + user = factories.UserFactory() + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="reader") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="reader" + ) + + invitation = factories.InvitationFactory(document=document) + abilities = invitation.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": False, + "partial_update": False, + "update": False, + } + + +@pytest.mark.parametrize("via", VIA) +def test_models_document_invitations_get_abilities_editor(via, mock_user_teams): + """Check abilities for a document editor with 'editor' role.""" + + user = factories.UserFactory() + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="editor") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="editor" + ) + + invitation = factories.InvitationFactory(document=document) + abilities = invitation.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": False, + "partial_update": False, + "update": False, + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_models_template_accesses.py b/submissions/devoteam/docs/src/backend/core/tests/test_models_template_accesses.py new file mode 100644 index 00000000..70a24164 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_models_template_accesses.py @@ -0,0 +1,419 @@ +""" +Unit tests for the TemplateAccess model +""" + +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_models_template_accesses_str(): + """ + The str representation should include user email, template title and role. + """ + user = factories.UserFactory(email="david.bowman@example.com") + access = factories.UserTemplateAccessFactory( + role="reader", + user=user, + template__title="admins", + ) + assert str(access) == "david.bowman@example.com is reader in template admins" + + +def test_models_template_accesses_unique_user(): + """Template accesses should be unique for a given couple of user and template.""" + access = factories.UserTemplateAccessFactory() + + with pytest.raises( + ValidationError, + match="This user is already in this template.", + ): + factories.UserTemplateAccessFactory(user=access.user, template=access.template) + + +def test_models_template_accesses_several_empty_teams(): + """A template can have several template accesses with an empty team.""" + access = factories.UserTemplateAccessFactory() + factories.UserTemplateAccessFactory(template=access.template) + + +def test_models_template_accesses_unique_team(): + """Template accesses should be unique for a given couple of team and template.""" + access = factories.TeamTemplateAccessFactory() + + with pytest.raises( + ValidationError, + match="This team is already in this template.", + ): + factories.TeamTemplateAccessFactory(team=access.team, template=access.template) + + +def test_models_template_accesses_several_null_users(): + """A template can have several template accesses with a null user.""" + access = factories.TeamTemplateAccessFactory() + factories.TeamTemplateAccessFactory(template=access.template) + + +def test_models_template_accesses_user_and_team_set(): + """User and team can't both be set on a template access.""" + with pytest.raises( + ValidationError, + match="Either user or team must be set, not both.", + ): + factories.UserTemplateAccessFactory(team="my-team") + + +def test_models_template_accesses_user_and_team_empty(): + """User and team can't both be empty on a template access.""" + with pytest.raises( + ValidationError, + match="Either user or team must be set, not both.", + ): + factories.UserTemplateAccessFactory(user=None) + + +# get_abilities + + +def test_models_template_access_get_abilities_anonymous(): + """Check abilities returned for an anonymous user.""" + access = factories.UserTemplateAccessFactory() + abilities = access.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_authenticated(): + """Check abilities returned for an authenticated user.""" + access = factories.UserTemplateAccessFactory() + user = factories.UserFactory() + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +# - for owner + + +def test_models_template_access_get_abilities_for_owner_of_self_allowed(): + """ + Check abilities of self access for the owner of a template when + there is more than one owner left. + """ + access = factories.UserTemplateAccessFactory(role="owner") + factories.UserTemplateAccessFactory(template=access.template, role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["administrator", "editor", "reader"], + } + + +def test_models_template_access_get_abilities_for_owner_of_self_last(): + """ + Check abilities of self access for the owner of a template when there is only one owner left. + """ + access = factories.UserTemplateAccessFactory(role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_owner_of_owner(): + """Check abilities of owner access for the owner of a template.""" + access = factories.UserTemplateAccessFactory(role="owner") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["administrator", "editor", "reader"], + } + + +def test_models_template_access_get_abilities_for_owner_of_administrator(): + """Check abilities of administrator access for the owner of a template.""" + access = factories.UserTemplateAccessFactory(role="administrator") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["owner", "editor", "reader"], + } + + +def test_models_template_access_get_abilities_for_owner_of_editor(): + """Check abilities of editor access for the owner of a template.""" + access = factories.UserTemplateAccessFactory(role="editor") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["owner", "administrator", "reader"], + } + + +def test_models_template_access_get_abilities_for_owner_of_reader(): + """Check abilities of reader access for the owner of a template.""" + access = factories.UserTemplateAccessFactory(role="reader") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["owner", "administrator", "editor"], + } + + +# - for administrator + + +def test_models_template_access_get_abilities_for_administrator_of_owner(): + """Check abilities of owner access for the administrator of a template.""" + access = factories.UserTemplateAccessFactory(role="owner") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_administrator_of_administrator(): + """Check abilities of administrator access for the administrator of a template.""" + access = factories.UserTemplateAccessFactory(role="administrator") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["editor", "reader"], + } + + +def test_models_template_access_get_abilities_for_administrator_of_editor(): + """Check abilities of editor access for the administrator of a template.""" + access = factories.UserTemplateAccessFactory(role="editor") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["administrator", "reader"], + } + + +def test_models_template_access_get_abilities_for_administrator_of_reader(): + """Check abilities of reader access for the administrator of a template.""" + access = factories.UserTemplateAccessFactory(role="reader") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["administrator", "editor"], + } + + +# - For editor + + +def test_models_template_access_get_abilities_for_editor_of_owner(): + """Check abilities of owner access for the editor of a template.""" + access = factories.UserTemplateAccessFactory(role="owner") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="editor" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_editor_of_administrator(): + """Check abilities of administrator access for the editor of a template.""" + access = factories.UserTemplateAccessFactory(role="administrator") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="editor" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_editor_of_editor_user( + django_assert_num_queries, +): + """Check abilities of editor access for the editor of a template.""" + access = factories.UserTemplateAccessFactory(role="editor") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="editor" + ).user + + with django_assert_num_queries(1): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +# - For reader + + +def test_models_template_access_get_abilities_for_reader_of_owner(): + """Check abilities of owner access for the reader of a template.""" + access = factories.UserTemplateAccessFactory(role="owner") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="reader" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_reader_of_administrator(): + """Check abilities of administrator access for the reader of a template.""" + access = factories.UserTemplateAccessFactory(role="administrator") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="reader" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_reader_of_reader_user( + django_assert_num_queries, +): + """Check abilities of reader access for the reader of a template.""" + access = factories.UserTemplateAccessFactory(role="reader") + factories.UserTemplateAccessFactory(template=access.template) # another one + user = factories.UserTemplateAccessFactory( + template=access.template, role="reader" + ).user + + with django_assert_num_queries(1): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset, e.g., with a query annotation.""" + access = factories.UserTemplateAccessFactory(role="reader") + user = factories.UserTemplateAccessFactory( + template=access.template, role="reader" + ).user + access.user_roles = ["reader"] + + with django_assert_num_queries(0): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "partial_update": False, + "set_role_to": [], + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_models_templates.py b/submissions/devoteam/docs/src/backend/core/tests/test_models_templates.py new file mode 100644 index 00000000..95f8fbde --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_models_templates.py @@ -0,0 +1,187 @@ +""" +Unit tests for the Template model +""" + +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_templates_str(): + """The str representation should be the title of the template.""" + template = factories.TemplateFactory(title="admins") + assert str(template) == "admins" + + +def test_models_templates_id_unique(): + """The "id" field should be unique.""" + template = factories.TemplateFactory() + with pytest.raises(ValidationError, match="Template with this Id already exists."): + factories.TemplateFactory(id=template.id) + + +def test_models_templates_title_null(): + """The "title" field should not be null.""" + with pytest.raises(ValidationError, match="This field cannot be null."): + models.Template.objects.create(title=None) + + +def test_models_templates_title_empty(): + """The "title" field should not be empty.""" + with pytest.raises(ValidationError, match="This field cannot be blank."): + models.Template.objects.create(title="") + + +def test_models_templates_title_max_length(): + """The "title" field should be 100 characters maximum.""" + factories.TemplateFactory(title="a" * 255) + with pytest.raises( + ValidationError, + match=r"Ensure this value has at most 255 characters \(it has 256\)\.", + ): + factories.TemplateFactory(title="a" * 256) + + +# get_abilities + + +def test_models_templates_get_abilities_anonymous_public(): + """Check abilities returned for an anonymous user if the template is public.""" + template = factories.TemplateFactory(is_public=True) + abilities = template.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "accesses_manage": False, + "partial_update": False, + "generate_document": True, + } + + +def test_models_templates_get_abilities_anonymous_not_public(): + """Check abilities returned for an anonymous user if the template is private.""" + template = factories.TemplateFactory(is_public=False) + abilities = template.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "accesses_manage": False, + "partial_update": False, + "generate_document": False, + } + + +def test_models_templates_get_abilities_authenticated_public(): + """Check abilities returned for an authenticated user if the user is public.""" + template = factories.TemplateFactory(is_public=True) + abilities = template.get_abilities(factories.UserFactory()) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "accesses_manage": False, + "partial_update": False, + "generate_document": True, + } + + +def test_models_templates_get_abilities_authenticated_not_public(): + """Check abilities returned for an authenticated user if the template is private.""" + template = factories.TemplateFactory(is_public=False) + abilities = template.get_abilities(factories.UserFactory()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "accesses_manage": False, + "partial_update": False, + "generate_document": False, + } + + +def test_models_templates_get_abilities_owner(): + """Check abilities returned for the owner of a template.""" + user = factories.UserFactory() + access = factories.UserTemplateAccessFactory(role="owner", user=user) + abilities = access.template.get_abilities(access.user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "accesses_manage": True, + "partial_update": True, + "generate_document": True, + } + + +def test_models_templates_get_abilities_administrator(): + """Check abilities returned for the administrator of a template.""" + access = factories.UserTemplateAccessFactory(role="administrator") + abilities = access.template.get_abilities(access.user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": True, + "accesses_manage": True, + "partial_update": True, + "generate_document": True, + } + + +def test_models_templates_get_abilities_editor_user(django_assert_num_queries): + """Check abilities returned for the editor of a template.""" + access = factories.UserTemplateAccessFactory(role="editor") + + with django_assert_num_queries(1): + abilities = access.template.get_abilities(access.user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": True, + "accesses_manage": False, + "partial_update": True, + "generate_document": True, + } + + +def test_models_templates_get_abilities_reader_user(django_assert_num_queries): + """Check abilities returned for the reader of a template.""" + access = factories.UserTemplateAccessFactory(role="reader") + + with django_assert_num_queries(1): + abilities = access.template.get_abilities(access.user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "accesses_manage": False, + "partial_update": False, + "generate_document": True, + } + + +def test_models_templates_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset e.g. with query annotation.""" + access = factories.UserTemplateAccessFactory(role="reader") + access.template.user_roles = ["reader"] + + with django_assert_num_queries(0): + abilities = access.template.get_abilities(access.user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "accesses_manage": False, + "partial_update": False, + "generate_document": True, + } diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_models_users.py b/submissions/devoteam/docs/src/backend/core/tests/test_models_users.py new file mode 100644 index 00000000..edea5bb9 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_models_users.py @@ -0,0 +1,46 @@ +""" +Unit tests for the User model +""" + +from unittest import mock + +from django.core.exceptions import ValidationError + +import pytest + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_models_users_str(): + """The str representation should be the email.""" + user = factories.UserFactory() + assert str(user) == user.email + + +def test_models_users_id_unique(): + """The "id" field should be unique.""" + user = factories.UserFactory() + with pytest.raises(ValidationError, match="User with this Id already exists."): + factories.UserFactory(id=user.id) + + +def test_models_users_send_mail_main_existing(): + """The "email_user' method should send mail to the user's email address.""" + user = factories.UserFactory() + + with mock.patch("django.core.mail.send_mail") as mock_send: + user.email_user("my subject", "my message") + + mock_send.assert_called_once_with("my subject", "my message", None, [user.email]) + + +def test_models_users_send_mail_main_missing(): + """The "email_user' method should fail if the user has no email address.""" + user = factories.UserFactory(email=None) + + with pytest.raises(ValueError) as excinfo: + user.email_user("my subject", "my message") + + assert str(excinfo.value) == "User has no email address." diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_services_ai_services.py b/submissions/devoteam/docs/src/backend/core/tests/test_services_ai_services.py new file mode 100644 index 00000000..ffa5c170 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_services_ai_services.py @@ -0,0 +1,84 @@ +""" +Test ai API endpoints in the impress core app. +""" + +from unittest.mock import MagicMock, patch + +from django.core.exceptions import ImproperlyConfigured +from django.test.utils import override_settings + +import pytest +from openai import OpenAIError + +from core.services.ai_services import AIService + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + "setting_name, setting_value", + [ + ("AI_BASE_URL", None), + ("AI_API_KEY", None), + ("AI_MODEL", None), + ], +) +def test_api_ai_setting_missing(setting_name, setting_value): + """Setting should be set""" + + with override_settings(**{setting_name: setting_value}): + with pytest.raises( + ImproperlyConfigured, + match="AI configuration not set", + ): + AIService() + + +@override_settings( + AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model" +) +@patch("openai.resources.chat.completions.Completions.create") +def test_api_ai__client_error(mock_create): + """Fail when the client raises an error""" + + mock_create.side_effect = OpenAIError("Mocked client error") + + with pytest.raises( + OpenAIError, + match="Mocked client error", + ): + AIService().transform("hello", "prompt") + + +@override_settings( + AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model" +) +@patch("openai.resources.chat.completions.Completions.create") +def test_api_ai__client_invalid_response(mock_create): + """Fail when the client response is invalid""" + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content=None))] + ) + + with pytest.raises( + RuntimeError, + match="AI response does not contain an answer", + ): + AIService().transform("hello", "prompt") + + +@override_settings( + AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model" +) +@patch("openai.resources.chat.completions.Completions.create") +def test_api_ai__success(mock_create): + """The AI request should work as expect when called with valid arguments.""" + + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content="Salut"))] + ) + + response = AIService().transform("hello", "prompt") + + assert response == {"answer": "Salut"} diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_services_collaboration_services.py b/submissions/devoteam/docs/src/backend/core/tests/test_services_collaboration_services.py new file mode 100644 index 00000000..5a915170 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_services_collaboration_services.py @@ -0,0 +1,185 @@ +""" +This module contains tests for the CollaborationService class in the +core.services.collaboration_services module. +""" + +import json +import re +from contextlib import contextmanager + +from django.core.exceptions import ImproperlyConfigured + +import pytest +import requests +import responses + +from core.services.collaboration_services import CollaborationService + + +@pytest.fixture +def mock_reset_connections(settings): + """ + Creates a context manager to mock the reset-connections endpoint for collaboration services. + Args: + settings: A settings object that contains the configuration for the collaboration API. + Returns: + A context manager function that mocks the reset-connections endpoint. + The context manager function takes the following parameters: + document_id (str): The ID of the document for which connections are being reset. + user_id (str, optional): The ID of the user making the request. Defaults to None. + Usage: + with mock_reset_connections(settings)(document_id, user_id) as mock: + # Your test code here + The context manager performs the following actions: + - Mocks the reset-connections endpoint using responses.RequestsMock. + - Sets the COLLABORATION_API_URL and COLLABORATION_SERVER_SECRET in the settings. + - Verifies that the reset-connections endpoint is called exactly once. + - Checks that the request URL and headers are correct. + - If user_id is provided, checks that the X-User-Id header is correct. + """ + + @contextmanager + def _mock_reset_connections(document_id, user_id=None): + with responses.RequestsMock() as rsps: + # Mock the reset-connections endpoint + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}reset-connections/?room={document_id}" + ) + rsps.add( + responses.POST, + endpoint_url, + json={}, + status=200, + ) + yield + + assert len(rsps.calls) == 1, ( + "Expected one call to reset-connections endpoint" + ) + request = rsps.calls[0].request + assert request.url == endpoint_url, f"Unexpected URL called: {request.url}" + assert ( + request.headers.get("Authorization") + == settings.COLLABORATION_SERVER_SECRET + ), "Incorrect Authorization header" + + if user_id: + assert request.headers.get("X-User-Id") == user_id, ( + "Incorrect X-User-Id header" + ) + + return _mock_reset_connections + + +def test_init_without_api_url(settings): + """Test that ImproperlyConfigured is raised when COLLABORATION_API_URL is None.""" + settings.COLLABORATION_API_URL = None + with pytest.raises(ImproperlyConfigured): + CollaborationService() + + +def test_init_with_api_url(settings): + """Test that the service initializes correctly when COLLABORATION_API_URL is set.""" + settings.COLLABORATION_API_URL = "http://example.com/" + service = CollaborationService() + assert isinstance(service, CollaborationService) + + +@responses.activate +def test_reset_connections_with_user_id(settings): + """Test reset_connections with a provided user_id.""" + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + service = CollaborationService() + + room = "room1" + user_id = "user123" + endpoint_url = "http://example.com/reset-connections/?room=" + room + + responses.add(responses.POST, endpoint_url, json={}, status=200) + + service.reset_connections(room, user_id) + + assert len(responses.calls) == 1 + request = responses.calls[0].request + + assert request.url == endpoint_url + assert request.headers.get("Authorization") == "secret-token" + assert request.headers.get("X-User-Id") == "user123" + + +@responses.activate +def test_reset_connections_without_user_id(settings): + """Test reset_connections without a user_id.""" + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + service = CollaborationService() + + room = "room1" + user_id = None + endpoint_url = "http://example.com/reset-connections/?room=" + room + + responses.add( + responses.POST, + endpoint_url, + json={}, + status=200, + ) + + service.reset_connections(room, user_id) + + assert len(responses.calls) == 1 + request = responses.calls[0].request + + assert request.url == endpoint_url + assert request.headers.get("Authorization") == "secret-token" + assert request.headers.get("X-User-Id") is None + + +@responses.activate +def test_reset_connections_non_200_response(settings): + """Test that an HTTPError is raised when the response status is not 200.""" + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + service = CollaborationService() + + room = "room1" + user_id = "user123" + endpoint_url = "http://example.com/reset-connections/?room=" + room + response_body = {"error": "Internal Server Error"} + + responses.add(responses.POST, endpoint_url, json=response_body, status=500) + + expected_exception_message = re.escape( + "Failed to notify WebSocket server. Status code: 500, Response: " + ) + re.escape(json.dumps(response_body)) + + with pytest.raises(requests.HTTPError, match=expected_exception_message): + service.reset_connections(room, user_id) + + assert len(responses.calls) == 1 + + +@responses.activate +def test_reset_connections_request_exception(settings): + """Test that an HTTPError is raised when a RequestException occurs.""" + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + service = CollaborationService() + + room = "room1" + user_id = "user123" + endpoint_url = "http://example.com/reset-connections?room=" + room + + responses.add( + responses.POST, + endpoint_url, + body=requests.exceptions.ConnectionError("Network error"), + ) + + with pytest.raises(requests.HTTPError, match="Failed to notify WebSocket server."): + service.reset_connections(room, user_id) + + assert len(responses.calls) == 1 diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_services_converter_services.py b/submissions/devoteam/docs/src/backend/core/tests/test_services_converter_services.py new file mode 100644 index 00000000..d65beed6 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_services_converter_services.py @@ -0,0 +1,147 @@ +"""Test converter services.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from core.services.converter_services import ( + InvalidResponseError, + MissingContentError, + ServiceUnavailableError, + ValidationError, + YdocConverter, +) + + +def test_auth_header(settings): + """Test authentication header generation.""" + settings.Y_PROVIDER_API_KEY = "test-key" + converter = YdocConverter() + assert converter.auth_header == "test-key" + + +def test_convert_markdown_empty_text(): + """Should raise ValidationError when text is empty.""" + converter = YdocConverter() + with pytest.raises(ValidationError, match="Input text cannot be empty"): + converter.convert_markdown("") + + +@patch("requests.post") +def test_convert_markdown_service_unavailable(mock_post): + """Should raise ServiceUnavailableError when service is unavailable.""" + converter = YdocConverter() + + mock_post.side_effect = requests.RequestException("Connection error") + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to conversion service", + ): + converter.convert_markdown("test text") + + +@patch("requests.post") +def test_convert_markdown_http_error(mock_post): + """Should raise ServiceUnavailableError when HTTP error occurs.""" + converter = YdocConverter() + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error") + mock_post.return_value = mock_response + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to conversion service", + ): + converter.convert_markdown("test text") + + +@patch("requests.post") +def test_convert_markdown_invalid_json_response(mock_post): + """Should raise InvalidResponseError when response is not valid JSON.""" + converter = YdocConverter() + + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_post.return_value = mock_response + + with pytest.raises( + InvalidResponseError, + match="Could not parse conversion service response", + ): + converter.convert_markdown("test text") + + +@patch("requests.post") +def test_convert_markdown_missing_content_field(mock_post, settings): + """Should raise MissingContentError when response is missing required field.""" + + settings.CONVERSION_API_CONTENT_FIELD = "expected_field" + + converter = YdocConverter() + + mock_response = MagicMock() + mock_response.json.return_value = {"wrong_field": "content"} + mock_post.return_value = mock_response + + with pytest.raises( + MissingContentError, + match="Response missing required field: expected_field", + ): + converter.convert_markdown("test text") + + +@patch("requests.post") +def test_convert_markdown_full_integration(mock_post, settings): + """Test full integration with all settings.""" + + settings.Y_PROVIDER_API_BASE_URL = "http://test.com/" + settings.Y_PROVIDER_API_KEY = "test-key" + settings.CONVERSION_API_ENDPOINT = "conversion-endpoint" + settings.CONVERSION_API_TIMEOUT = 5 + settings.CONVERSION_API_CONTENT_FIELD = "content" + + converter = YdocConverter() + + expected_content = {"converted": "content"} + mock_response = MagicMock() + mock_response.json.return_value = {"content": expected_content} + mock_post.return_value = mock_response + + result = converter.convert_markdown("test markdown") + + assert result == expected_content + mock_post.assert_called_once_with( + "http://test.com/conversion-endpoint/", + json={"content": "test markdown"}, + headers={ + "Authorization": "test-key", + "Content-Type": "application/json", + }, + timeout=5, + verify=False, + ) + + +@patch("requests.post") +def test_convert_markdown_timeout(mock_post): + """Should raise ServiceUnavailableError when request times out.""" + converter = YdocConverter() + + mock_post.side_effect = requests.Timeout("Request timed out") + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to conversion service", + ): + converter.convert_markdown("test text") + + +def test_convert_markdown_none_input(): + """Should raise ValidationError when input is None.""" + converter = YdocConverter() + + with pytest.raises(ValidationError, match="Input text cannot be empty"): + converter.convert_markdown(None) diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_settings.py b/submissions/devoteam/docs/src/backend/core/tests/test_settings.py new file mode 100644 index 00000000..44c7c8f7 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_settings.py @@ -0,0 +1,30 @@ +""" +Unit tests for the User model +""" + +import pytest + +from impress.settings import Base + + +def test_invalid_settings_oidc_email_configuration(): + """ + The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings + should not be both set to True simultaneously. + """ + + class TestSettings(Base): + """Fake test settings.""" + + OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True + OIDC_ALLOW_DUPLICATE_EMAILS = True + + # The validation is performed during post_setup + with pytest.raises(ValueError) as excinfo: + TestSettings().post_setup() + + # Check the exception message + assert str(excinfo.value) == ( + "Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and " + "OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. " + ) diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_utils.py b/submissions/devoteam/docs/src/backend/core/tests/test_utils.py new file mode 100644 index 00000000..37b2e32d --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_utils.py @@ -0,0 +1,77 @@ +"""Test util base64_yjs_to_text.""" + +import base64 +import uuid + +import pycrdt + +from core import utils + +# This base64 string is an example of what is saved in the database. +# This base64 is generated from the blocknote editor, it contains +# the text \n# *Hello* \n- w**or**ld +TEST_BASE64_STRING = ( + "AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh" + "aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVI" + "ZWxsb4b17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y" + "1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm" + "YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y" + "AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt" + "BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE" + "bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck" + "ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH" + "ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv" + "bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA" + "9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J" + "dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA" +) + + +def test_utils_base64_yjs_to_text(): + """Test extract text from saved yjs document""" + assert utils.base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld" + + +def test_utils_base64_yjs_to_xml(): + """Test extract xml from saved yjs document""" + content = utils.base64_yjs_to_xml(TEST_BASE64_STRING) + assert ( + 'Hello' + in content + or 'Hello' + in content + ) + assert ( + 'world' + in content + ) + + +def test_utils_extract_attachments(): + """ + All attachment keys in the document content should be extracted. + """ + document_id = uuid.uuid4() + image_key1 = f"{document_id!s}/attachments/{uuid.uuid4()!s}.png" + image_url1 = f"http://localhost/media/{image_key1:s}" + + image_key2 = f"{uuid.uuid4()!s}/attachments/{uuid.uuid4()!s}.png" + image_url2 = f"http://localhost/{image_key2:s}" + + image_key3 = f"{uuid.uuid4()!s}/attachments/{uuid.uuid4()!s}.png" + image_url3 = f"http://localhost/media/{image_key3:s}" + + ydoc = pycrdt.Doc() + frag = pycrdt.XmlFragment( + [ + pycrdt.XmlElement("img", {"src": image_url1}), + pycrdt.XmlElement("img", {"src": image_url2}), + pycrdt.XmlElement("p", {}, [pycrdt.XmlText(image_url3)]), + ] + ) + ydoc["document-store"] = frag + + update = ydoc.get_update() + base64_string = base64.b64encode(update).decode("utf-8") + # image_key2 is missing the "/media/" part and shouldn't get extracted + assert utils.extract_attachments(base64_string) == [image_key1, image_key3] diff --git a/submissions/devoteam/docs/src/backend/core/tests/test_utils_filter_descendants.py b/submissions/devoteam/docs/src/backend/core/tests/test_utils_filter_descendants.py new file mode 100644 index 00000000..cf37a0d9 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/tests/test_utils_filter_descendants.py @@ -0,0 +1,163 @@ +""" +Unit tests for the filter_root_paths utility function. +""" + +from core.utils import filter_descendants + + +def test_utils_filter_descendants_success(): + """ + The `filter_descendants` function should correctly identify descendant paths + from a given list of paths and root paths. + + This test verifies that the function returns only the paths that have a prefix + matching one of the root paths. + """ + paths = [ + "0001", + "00010001", + "000100010001", + "000100010002", + "000100020001", + "000100020002", + "0002", + "00020001", + "00020002", + "00030001", + "000300010001", + "00030002", + "0004", + "000400010003", + "0004000100030001", + "000400010004", + ] + root_paths = [ + "0001", + "0002", + "000400010003", + ] + filtered_paths = filter_descendants(paths, root_paths, skip_sorting=True) + assert filtered_paths == [ + "0001", + "00010001", + "000100010001", + "000100010002", + "000100020001", + "000100020002", + "0002", + "00020001", + "00020002", + "000400010003", + "0004000100030001", + ] + + +def test_utils_filter_descendants_sorting(): + """ + The `filter_descendants` function should handle unsorted input when sorting is enabled. + + This test verifies that the function sorts the input if sorting is not skipped + and still correctly identifies accessible descendant paths. + """ + paths = [ + "000300010001", + "000100010002", + "0001", + "00010001", + "000100010001", + "000100020002", + "000100020001", + "0002", + "00020001", + "00020002", + "00030001", + "00030002", + "0004000100030001", + "0004", + "000400010003", + "000400010004", + ] + root_paths = [ + "0002", + "000400010003", + "0001", + ] + filtered_paths = filter_descendants(paths, root_paths) + assert filtered_paths == [ + "0001", + "00010001", + "000100010001", + "000100010002", + "000100020001", + "000100020002", + "0002", + "00020001", + "00020002", + "000400010003", + "0004000100030001", + ] + + filtered_paths = filter_descendants(paths, root_paths, skip_sorting=True) + assert filtered_paths == [ + "0001", + "00010001", + "000100010001", + "000100010002", + "000100020001", + "000100020002", + "0002", + "00020001", + "00020002", + "000400010003", + "0004000100030001", + ] + + +def test_utils_filter_descendants_empty(): + """ + The function should return an empty list if one or both inputs are empty. + """ + assert not filter_descendants([], ["0001"]) + assert not filter_descendants(["0001"], []) + assert not filter_descendants([], []) + + +def test_utils_filter_descendants_no_match(): + """ + The function should return an empty list if no path starts with any root path. + """ + paths = ["0001", "0002", "0003"] + root_paths = ["0004", "0005"] + assert not filter_descendants(paths, root_paths, skip_sorting=True) + + +def test_utils_filter_descendants_exact_match(): + """ + The function should include paths that exactly match a root path. + """ + paths = ["0001", "0002", "0003"] + root_paths = ["0001", "0002"] + assert filter_descendants(paths, root_paths, skip_sorting=True) == ["0001", "0002"] + + +def test_utils_filter_descendants_single_root_matches_all(): + """ + A single root path should match all its descendants. + """ + paths = ["0001", "00010001", "000100010001", "00010002"] + root_paths = ["0001"] + assert filter_descendants(paths, root_paths) == [ + "0001", + "00010001", + "000100010001", + "00010002", + ] + + +def test_utils_filter_descendants_path_shorter_than_root(): + """ + A path shorter than any root path should not match. + """ + paths = ["0001", "0002"] + root_paths = ["00010001"] + assert not filter_descendants(paths, root_paths) diff --git a/submissions/devoteam/docs/src/backend/core/urls.py b/submissions/devoteam/docs/src/backend/core/urls.py new file mode 100644 index 00000000..05441895 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/urls.py @@ -0,0 +1,59 @@ +"""URL configuration for the core app.""" + +from django.conf import settings +from django.urls import include, path, re_path + +from lasuite.oidc_login.urls import urlpatterns as oidc_urls +from rest_framework.routers import DefaultRouter + +from core.api import viewsets + +# - Main endpoints +router = DefaultRouter() +router.register("templates", viewsets.TemplateViewSet, basename="templates") +router.register("documents", viewsets.DocumentViewSet, basename="documents") +router.register("users", viewsets.UserViewSet, basename="users") + +# - Routes nested under a document +document_related_router = DefaultRouter() +document_related_router.register( + "accesses", + viewsets.DocumentAccessViewSet, + basename="document_accesses", +) +document_related_router.register( + "invitations", + viewsets.InvitationViewset, + basename="invitations", +) + + +# - Routes nested under a template +template_related_router = DefaultRouter() +template_related_router.register( + "accesses", + viewsets.TemplateAccessViewSet, + basename="template_accesses", +) + + +urlpatterns = [ + path( + f"api/{settings.API_VERSION}/", + include( + [ + *router.urls, + *oidc_urls, + re_path( + r"^documents/(?P[0-9a-z-]*)/", + include(document_related_router.urls), + ), + re_path( + r"^templates/(?P[0-9a-z-]*)/", + include(template_related_router.urls), + ), + ] + ), + ), + path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()), +] diff --git a/submissions/devoteam/docs/src/backend/core/utils.py b/submissions/devoteam/docs/src/backend/core/utils.py new file mode 100644 index 00000000..780431f4 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/core/utils.py @@ -0,0 +1,76 @@ +"""Utils for the core app.""" + +import base64 +import re + +import pycrdt +from bs4 import BeautifulSoup + +from core import enums + + +def filter_descendants(paths, root_paths, skip_sorting=False): + """ + Filters paths to keep only those that are descendants of any path in root_paths. + + A path is considered a descendant of a root path if it starts with the root path. + If `skip_sorting` is not set to True, the function will sort both lists before + processing because both `paths` and `root_paths` need to be in lexicographic order + before going through the algorithm. + + Args: + paths (iterable of str): List of paths to be filtered. + root_paths (iterable of str): List of paths to check as potential prefixes. + skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted. + + Returns: + list of str: A list of sorted paths that are descendants of any path in `root_paths`. + """ + results = [] + i = 0 + n = len(root_paths) + + if not skip_sorting: + paths.sort() + root_paths.sort() + + for path in paths: + # Try to find a matching prefix in the sorted accessible paths + while i < n: + if path.startswith(root_paths[i]): + results.append(path) + break + if root_paths[i] < path: + i += 1 + else: + # If paths[i] > path, no need to keep searching + break + return results + + +def base64_yjs_to_xml(base64_string): + """Extract xml from base64 yjs document.""" + + decoded_bytes = base64.b64decode(base64_string) + # uint8_array = bytearray(decoded_bytes) + + doc = pycrdt.Doc() + doc.apply_update(decoded_bytes) + return str(doc.get("document-store", type=pycrdt.XmlFragment)) + + +def base64_yjs_to_text(base64_string): + """Extract text from base64 yjs document.""" + + blocknote_structure = base64_yjs_to_xml(base64_string) + soup = BeautifulSoup(blocknote_structure, "lxml-xml") + return soup.get_text(separator=" ", strip=True) + + +def extract_attachments(content): + """Helper method to extract media paths from a document's content.""" + if not content: + return [] + + xml_content = base64_yjs_to_xml(content) + return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content) diff --git a/submissions/devoteam/docs/src/backend/demo/__init__.py b/submissions/devoteam/docs/src/backend/demo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/demo/data/template/code.txt b/submissions/devoteam/docs/src/backend/demo/data/template/code.txt new file mode 100644 index 00000000..229af08d --- /dev/null +++ b/submissions/devoteam/docs/src/backend/demo/data/template/code.txt @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/submissions/devoteam/docs/src/backend/demo/data/template/css.txt b/submissions/devoteam/docs/src/backend/demo/data/template/css.txt new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/demo/defaults.py b/submissions/devoteam/docs/src/backend/demo/defaults.py new file mode 100644 index 00000000..4b082e39 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/demo/defaults.py @@ -0,0 +1,18 @@ +"""Parameters that define how the demo site will be built.""" + +NB_OBJECTS = { + "users": 50, + "docs": 50, + "max_users_per_document": 50, +} + +DEV_USERS = [ + {"username": "impress", "email": "impress@impress.world", "language": "en-us"}, + {"username": "user-e2e-webkit", "email": "user@webkit.e2e", "language": "en-us"}, + {"username": "user-e2e-firefox", "email": "user@firefox.e2e", "language": "en-us"}, + { + "username": "user-e2e-chromium", + "email": "user@chromium.e2e", + "language": "en-us", + }, +] diff --git a/submissions/devoteam/docs/src/backend/demo/management/__init__.py b/submissions/devoteam/docs/src/backend/demo/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/demo/management/commands/__init__.py b/submissions/devoteam/docs/src/backend/demo/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/demo/management/commands/create_demo.py b/submissions/devoteam/docs/src/backend/demo/management/commands/create_demo.py new file mode 100644 index 00000000..74c02709 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/demo/management/commands/create_demo.py @@ -0,0 +1,253 @@ +# ruff: noqa: S311, S106 +"""create_demo management command""" + +import logging +import math +import random +import time +from collections import defaultdict + +from django import db +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from faker import Faker + +from core import models + +from demo import defaults + +fake = Faker() + +logger = logging.getLogger("impress.commands.demo.create_demo") + + +def random_true_with_probability(probability): + """return True with the requested probability, False otherwise.""" + return random.random() < probability + + +class BulkQueue: + """A utility class to create Django model instances in bulk by just pushing to a queue.""" + + BATCH_SIZE = 20000 + + def __init__(self, stdout, *args, **kwargs): + """Define the queue as a dict of lists.""" + self.queue = defaultdict(list) + self.stdout = stdout + + def _bulk_create(self, objects): + """Actually create instances in bulk in the database.""" + if not objects: + return + + objects[0]._meta.model.objects.bulk_create(objects, ignore_conflicts=False) # noqa: SLF001 + # In debug mode, Django keeps query cache which creates a memory leak in this case + db.reset_queries() + self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001 + + def push(self, obj): + """Add a model instance to queue to that it gets created in bulk.""" + objects = self.queue[obj._meta.model.__name__] # noqa: SLF001 + objects.append(obj) + if len(objects) > self.BATCH_SIZE: + self._bulk_create(objects) + self.stdout.write(".", ending="") + + def flush(self): + """Flush the queue after creating the remaining model instances.""" + for objects in self.queue.values(): + self._bulk_create(objects) + + +class Timeit: + """A utility context manager/method decorator to time execution.""" + + total_time = 0 + + def __init__(self, stdout, sentence=None): + """Set the sentence to be displayed for timing information.""" + self.sentence = sentence + self.start = None + self.stdout = stdout + + def __call__(self, func): + """Behavior on call for use as a method decorator.""" + + def timeit_wrapper(*args, **kwargs): + """wrapper to trigger/stop the timer before/after function call.""" + self.__enter__() + result = func(*args, **kwargs) + self.__exit__(None, None, None) + return result + + return timeit_wrapper + + def __enter__(self): + """Start timer upon entering context manager.""" + self.start = time.perf_counter() + if self.sentence: + self.stdout.write(self.sentence, ending=".") + + def __exit__(self, exc_type, exc_value, exc_tb): + """Stop timer and display result upon leaving context manager.""" + if exc_type is not None: + raise exc_type(exc_value) + end = time.perf_counter() + elapsed_time = end - self.start + if self.sentence: + self.stdout.write(f" Took {elapsed_time:g} seconds") + + self.__class__.total_time += elapsed_time + return elapsed_time + + +def create_demo(stdout): + """ + Create a database with demo data for developers to work in a realistic environment. + The code is engineered to create a huge number of objects fast. + """ + + queue = BulkQueue(stdout) + + with Timeit(stdout, "Creating users"): + name_size = int(math.sqrt(defaults.NB_OBJECTS["users"])) + first_names = [fake.first_name() for _ in range(name_size)] + last_names = [fake.last_name() for _ in range(name_size)] + for i in range(defaults.NB_OBJECTS["users"]): + first_name = random.choice(first_names) + queue.push( + models.User( + admin_email=f"user{i:d}@example.com", + email=f"user{i:d}@example.com", + password="!", + is_superuser=False, + is_active=True, + is_staff=False, + short_name=first_name, + full_name=f"{first_name:s} {random.choice(last_names):s}", + language=random.choice(settings.LANGUAGES)[0], + ) + ) + queue.flush() + + users_ids = list(models.User.objects.values_list("id", flat=True)) + + with Timeit(stdout, "Creating documents"): + for i in range(defaults.NB_OBJECTS["docs"]): + # pylint: disable=protected-access + key = models.Document._int2str(i) # noqa: SLF001 + padding = models.Document.alphabet[0] * (models.Document.steplen - len(key)) + queue.push( + models.Document( + depth=1, + path=f"{padding}{key}", + creator_id=random.choice(users_ids), + title=fake.sentence(nb_words=4), + link_reach=models.LinkReachChoices.AUTHENTICATED + if random_true_with_probability(0.5) + else random.choice(models.LinkReachChoices.values), + ) + ) + + queue.flush() + + with Timeit(stdout, "Creating docs accesses"): + docs_ids = list(models.Document.objects.values_list("id", flat=True)) + for doc_id in docs_ids: + for user_id in random.sample( + users_ids, + random.randint(1, defaults.NB_OBJECTS["max_users_per_document"]), + ): + role = random.choice(models.RoleChoices.choices) + queue.push( + models.DocumentAccess( + document_id=doc_id, user_id=user_id, role=role[0] + ) + ) + queue.flush() + + with Timeit(stdout, "Creating development users"): + for dev_user in defaults.DEV_USERS: + queue.push( + models.User( + admin_email=dev_user["email"], + email=dev_user["email"], + sub=dev_user["email"], + password="!", + is_superuser=False, + is_active=True, + is_staff=False, + language=dev_user["language"] + or random.choice(settings.LANGUAGES)[0], + ) + ) + + queue.flush() + + with Timeit(stdout, "Creating docs accesses on development users"): + for dev_user in defaults.DEV_USERS: + docs_ids = list(models.Document.objects.values_list("id", flat=True)) + user_id = models.User.objects.get(email=dev_user["email"]).id + + for doc_id in docs_ids: + role = random.choice(models.RoleChoices.choices) + queue.push( + models.DocumentAccess( + document_id=doc_id, user_id=user_id, role=role[0] + ) + ) + + queue.flush() + + with Timeit(stdout, "Creating Template"): + with open( + file="demo/data/template/code.txt", mode="r", encoding="utf-8" + ) as text_file: + code_data = text_file.read() + + with open( + file="demo/data/template/css.txt", mode="r", encoding="utf-8" + ) as text_file: + css_data = text_file.read() + + queue.push( + models.Template( + id="baca9e2a-59fb-42ef-b5c6-6f6b05637111", + title="Demo Template", + description="This is the demo template", + code=code_data, + css=css_data, + is_public=True, + ) + ) + queue.flush() + + +class Command(BaseCommand): + """A management command to create a demo database.""" + + help = __doc__ + + def add_arguments(self, parser): + """Add argument to require forcing execution when not in debug mode.""" + parser.add_argument( + "-f", + "--force", + action="store_true", + default=False, + help="Force command execution despite DEBUG is set to False", + ) + + def handle(self, *args, **options): + """Handling of the management command.""" + if not settings.DEBUG and not options["force"]: + raise CommandError( + ( + "This command is not meant to be used in production environment " + "except you know what you are doing, if so use --force parameter" + ) + ) + + create_demo(self.stdout) diff --git a/submissions/devoteam/docs/src/backend/demo/management/commands/createsuperuser.py b/submissions/devoteam/docs/src/backend/demo/management/commands/createsuperuser.py new file mode 100644 index 00000000..9c5ab786 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/demo/management/commands/createsuperuser.py @@ -0,0 +1,47 @@ +"""Management user to create a superuser.""" + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +UserModel = get_user_model() + + +class Command(BaseCommand): + """Management command to create a superuser from and email and password.""" + + help = "Create a superuser with an email and a password" + + def add_arguments(self, parser): + """Define required arguments "email" and "password".""" + parser.add_argument( + "--email", + help=("Email for the user."), + ) + parser.add_argument( + "--password", + help="Password for the user.", + ) + + def handle(self, *args, **options): + """ + Given an email and a password, create a superuser or upgrade the existing + user to superuser status. + """ + email = options.get("email") + try: + user = UserModel.objects.get(admin_email=email) + except UserModel.DoesNotExist: + user = UserModel(admin_email=email) + message = "Superuser created successfully." + else: + if user.is_superuser and user.is_staff: + message = "Superuser already exists." + else: + message = "User already existed and was upgraded to superuser." + + user.is_superuser = True + user.is_staff = True + user.set_password(options["password"]) + user.save() + + self.stdout.write(self.style.SUCCESS(message)) diff --git a/submissions/devoteam/docs/src/backend/demo/tests/__init__.py b/submissions/devoteam/docs/src/backend/demo/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submissions/devoteam/docs/src/backend/demo/tests/test_commands_create_demo.py b/submissions/devoteam/docs/src/backend/demo/tests/test_commands_create_demo.py new file mode 100644 index 00000000..fef8a488 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/demo/tests/test_commands_create_demo.py @@ -0,0 +1,41 @@ +"""Test the `create_demo` management command""" + +from unittest import mock + +from django.core.management import call_command +from django.test import override_settings + +import pytest + +from core import models + +pytestmark = pytest.mark.django_db + + +@mock.patch( + "demo.defaults.NB_OBJECTS", + { + "users": 10, + "docs": 10, + "max_users_per_document": 5, + }, +) +@override_settings(DEBUG=True) +def test_commands_create_demo(): + """The create_demo management command should create objects as expected.""" + call_command("create_demo") + + assert models.Template.objects.count() == 1 + assert models.User.objects.count() >= 10 + assert models.Document.objects.count() >= 10 + assert models.DocumentAccess.objects.count() > 10 + + # assert dev users have doc accesses + user = models.User.objects.get(email="impress@impress.world") + assert models.DocumentAccess.objects.filter(user=user).exists() + user = models.User.objects.get(email="user@webkit.e2e") + assert models.DocumentAccess.objects.filter(user=user).exists() + user = models.User.objects.get(email="user@firefox.e2e") + assert models.DocumentAccess.objects.filter(user=user).exists() + user = models.User.objects.get(email="user@chromium.e2e") + assert models.DocumentAccess.objects.filter(user=user).exists() diff --git a/submissions/devoteam/docs/src/backend/impress/__init__.py b/submissions/devoteam/docs/src/backend/impress/__init__.py new file mode 100644 index 00000000..36f27b88 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/impress/__init__.py @@ -0,0 +1,5 @@ +"""Impress package. Import the celery app early to load shared task form dependencies.""" + +from .celery_app import app as celery_app + +__all__ = ["celery_app"] diff --git a/submissions/devoteam/docs/src/backend/impress/celery_app.py b/submissions/devoteam/docs/src/backend/impress/celery_app.py new file mode 100644 index 00000000..e38c5707 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/impress/celery_app.py @@ -0,0 +1,26 @@ +"""Impress celery configuration file.""" + +import os + +from celery import Celery +from configurations.importer import install + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings") +os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + +install(check_options=True) + +# Can not be loaded only after install call. +from django.conf import settings # pylint: disable=wrong-import-position + +app = Celery("impress") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) diff --git a/submissions/devoteam/docs/src/backend/impress/configuration/theme/default.json b/submissions/devoteam/docs/src/backend/impress/configuration/theme/default.json new file mode 100644 index 00000000..29200ada --- /dev/null +++ b/submissions/devoteam/docs/src/backend/impress/configuration/theme/default.json @@ -0,0 +1,129 @@ +{ + "footer": { + "default": { + "logo": { + "src": "/assets/icon-docs.svg", + "width": "54px", + "alt": "Docs Logo", + "withTitle": true + }, + "externalLinks": [ + { + "label": "Github", + "href": "https://github.com/suitenumerique/docs/" + }, + { + "label": "DINUM", + "href": "https://www.numerique.gouv.fr/dinum/" + }, + { + "label": "ZenDiS", + "href": "https://zendis.de/" + }, + { + "label": "BlockNote.js", + "href": "https://www.blocknotejs.org/" + } + ], + "bottomInformation": { + "label": "Unless otherwise stated, all content on this site is under", + "link": { + "label": "licence etalab-2.0", + "href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md" + } + } + }, + "en": { + "legalLinks": [ + { + "label": "Legal Notice", + "href": "#" + }, + { + "label": "Personal data and cookies", + "href": "#" + }, + { + "label": "Accessibility", + "href": "#" + } + ], + "bottomInformation": { + "label": "Unless otherwise stated, all content on this site is under", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "fr": { + "legalLinks": [ + { + "label": "Mentions légales", + "href": "#" + }, + { + "label": "Données personnelles et cookies", + "href": "#" + }, + { + "label": "Accessibilité", + "href": "#" + } + ], + "bottomInformation": { + "label": "Sauf mention contraire, tout le contenu de ce site est sous", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "de": { + "legalLinks": [ + { + "label": "Impressum", + "href": "#" + }, + { + "label": "Personenbezogene Daten und Cookies", + "href": "#" + }, + { + "label": "Barrierefreiheit", + "href": "#" + } + ], + "bottomInformation": { + "label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "nl": { + "legalLinks": [ + { + "label": "Wettelijke bepalingen", + "href": "#" + }, + { + "label": "Persoonlijke gegevens en cookies", + "href": "#" + }, + { + "label": "Toegankelijkheid", + "href": "#" + } + ], + "bottomInformation": { + "label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + } + } +} diff --git a/submissions/devoteam/docs/src/backend/impress/settings.py b/submissions/devoteam/docs/src/backend/impress/settings.py new file mode 100644 index 00000000..737bb338 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/impress/settings.py @@ -0,0 +1,979 @@ +""" +Django settings for impress project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" + +import os +import tomllib +from socket import gethostbyname, gethostname + +from django.utils.translation import gettext_lazy as _ + +import sentry_sdk +from configurations import Configuration, values +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.logging import ignore_logger + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data")) + + +def get_release(): + """ + Get the current release of the application + """ + try: + with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f: + pyproject_data = tomllib.load(f) + return pyproject_data["project"]["version"] + except (FileNotFoundError, KeyError): + return "NA" # Default: not available + + +class Base(Configuration): + """ + This is the base configuration every configuration (aka environment) should inherit from. It + is recommended to configure third-party applications by creating a configuration mixins in + ./configurations and compose the Base configuration with those mixins. + + It depends on an environment variable that SHOULD be defined: + + * DJANGO_SECRET_KEY + + You may also want to override default configuration by setting the following environment + variables: + + * SENTRY_DSN + * DB_NAME + * DB_HOST + * DB_PASSWORD + * DB_USER + """ + + DEBUG = False + USE_SWAGGER = False + + API_VERSION = "v1.0" + + # Security + ALLOWED_HOSTS = values.ListValue([]) + SECRET_KEY = values.Value(None) + SERVER_TO_SERVER_API_TOKENS = values.ListValue([]) + + # Application definition + ROOT_URLCONF = "impress.urls" + WSGI_APPLICATION = "impress.wsgi.application" + + # Database + DATABASES = { + "default": { + "ENGINE": values.Value( + "django.db.backends.postgresql_psycopg2", + environ_name="DB_ENGINE", + environ_prefix=None, + ), + "NAME": values.Value( + "impress", environ_name="DB_NAME", environ_prefix=None + ), + "USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None), + "PASSWORD": values.Value( + "pass", environ_name="DB_PASSWORD", environ_prefix=None + ), + "HOST": values.Value( + "localhost", environ_name="DB_HOST", environ_prefix=None + ), + "PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None), + } + } + DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + + # Static files (CSS, JavaScript, Images) + STATIC_URL = "/static/" + STATIC_ROOT = os.path.join(DATA_DIR, "static") + MEDIA_URL = "/media/" + MEDIA_ROOT = os.path.join(DATA_DIR, "media") + MEDIA_BASE_URL = values.Value( + None, environ_name="MEDIA_BASE_URL", environ_prefix=None + ) + + SITE_ID = 1 + + STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + }, + "staticfiles": { + "BACKEND": values.Value( + "whitenoise.storage.CompressedManifestStaticFilesStorage", + environ_name="STORAGES_STATICFILES_BACKEND", + ), + }, + } + + # Media + AWS_S3_ENDPOINT_URL = values.Value( + environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None + ) + AWS_S3_ACCESS_KEY_ID = values.Value( + environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None + ) + AWS_S3_SECRET_ACCESS_KEY = values.Value( + environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None + ) + AWS_S3_REGION_NAME = values.Value( + environ_name="AWS_S3_REGION_NAME", environ_prefix=None + ) + AWS_STORAGE_BUCKET_NAME = values.Value( + "impress-media-storage", + environ_name="AWS_STORAGE_BUCKET_NAME", + environ_prefix=None, + ) + + # Document images + DOCUMENT_IMAGE_MAX_SIZE = values.Value( + 10 * (2**20), # 10MB + environ_name="DOCUMENT_IMAGE_MAX_SIZE", + environ_prefix=None, + ) + + DOCUMENT_UNSAFE_MIME_TYPES = [ + # Executable Files + "application/x-msdownload", + "application/x-bat", + "application/x-dosexec", + "application/x-sh", + "application/x-ms-dos-executable", + "application/x-msi", + "application/java-archive", + "application/octet-stream", + # Dynamic Web Pages + "application/x-httpd-php", + "application/x-asp", + "application/x-aspx", + "application/jsp", + "application/xhtml+xml", + "application/x-python-code", + "application/x-perl", + "text/html", + "text/javascript", + "text/x-php", + # System Files + "application/x-msdownload", + "application/x-sys", + "application/x-drv", + "application/cpl", + "application/x-apple-diskimage", + # Script Files + "application/javascript", + "application/x-vbscript", + "application/x-powershell", + "application/x-shellscript", + # Compressed/Archive Files + "application/zip", + "application/x-tar", + "application/gzip", + "application/x-bzip2", + "application/x-7z-compressed", + "application/x-rar", + "application/x-rar-compressed", + "application/x-compress", + "application/x-lzma", + # Macros in Documents + "application/vnd.ms-word", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/vnd.ms-word.document.macroenabled.12", + "application/vnd.ms-excel.sheet.macroenabled.12", + "application/vnd.ms-powerpoint.presentation.macroenabled.12", + # Disk Images & Virtual Disk Files + "application/x-iso9660-image", + "application/x-vmdk", + "application/x-apple-diskimage", + "application/x-dmg", + # Other Dangerous MIME Types + "application/x-ms-application", + "application/x-msdownload", + "application/x-shockwave-flash", + "application/x-silverlight-app", + "application/x-java-vm", + "application/x-bittorrent", + "application/hta", + "application/x-csh", + "application/x-ksh", + "application/x-ms-regedit", + "application/x-msdownload", + "application/xml", + ] + + # Document versions + DOCUMENT_VERSIONS_PAGE_SIZE = 50 + + # Internationalization + # https://docs.djangoproject.com/en/3.1/topics/i18n/ + + # Languages + LANGUAGE_CODE = values.Value("en-us") + # cookie & language is set from frontend + LANGUAGE_COOKIE_NAME = "docs_language" + LANGUAGE_COOKIE_PATH = "/" + + DRF_NESTED_MULTIPART_PARSER = { + # output of parser is converted to querydict + # if is set to False, dict python is returned + "querydict": False, + } + + # Careful! Languages should be ordered by priority, as this tuple is used to get + # fallback/default languages throughout the app. + LANGUAGES = values.SingleNestedTupleValue( + ( + ("en-us", "English"), + ("fr-fr", "Français"), + ("de-de", "Deutsch"), + ("nl-nl", "Nederlands"), + ("es-es", "Español"), + ) + ) + + LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),) + + TIME_ZONE = "UTC" + USE_I18N = True + USE_TZ = True + + # Templates + TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "templates")], + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.csrf", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.request", + "django.template.context_processors.tz", + ], + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + }, + }, + ] + + MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "dockerflow.django.middleware.DockerflowMiddleware", + ] + + AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "core.authentication.backends.OIDCAuthenticationBackend", + ] + + # Django applications from the highest priority to the lowest + INSTALLED_APPS = [ + # impress + "core", + "demo", + "drf_spectacular", + # Third party apps + "corsheaders", + "django_filters", + "dockerflow.django", + "rest_framework", + "parler", + "treebeard", + "easy_thumbnails", + # Django + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.postgres", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + # OIDC third party + "mozilla_django_oidc", + "lasuite.malware_detection", + ] + + # Cache + CACHES = { + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, + } + + REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "mozilla_django_oidc.contrib.drf.OIDCAuthentication", + "rest_framework.authentication.SessionAuthentication", + ), + "DEFAULT_PARSER_CLASSES": [ + "rest_framework.parsers.JSONParser", + "nested_multipart_parser.drf.DrfNestedParser", + ], + "DEFAULT_RENDERER_CLASSES": [ + # 🔒️ Disable BrowsableAPIRenderer which provides forms allowing a user to + # see all the data in the database (ie a serializer with a ForeignKey field + # will generate a form with a field with all possible values of the FK). + "rest_framework.renderers.JSONRenderer", + ], + "EXCEPTION_HANDLER": "core.api.exception_handler", + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_THROTTLE_RATES": { + "user_list_sustained": values.Value( + default="180/hour", + environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED", + environ_prefix=None, + ), + "user_list_burst": values.Value( + default="30/minute", + environ_name="API_USERS_LIST_THROTTLE_RATE_BURST", + environ_prefix=None, + ), + }, + } + + SPECTACULAR_SETTINGS = { + "TITLE": "Impress API", + "DESCRIPTION": "This is the impress API schema.", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "ENABLE_DJANGO_DEPLOY_CHECK": values.BooleanValue( + default=False, + environ_name="SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK", + ), + "COMPONENT_SPLIT_REQUEST": True, + # OTHER SETTINGS + "SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead + "SWAGGER_UI_FAVICON_HREF": "SIDECAR", + "REDOC_DIST": "SIDECAR", + } + + TRASHBIN_CUTOFF_DAYS = values.Value( + 30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None + ) + + # Mail + EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend") + EMAIL_BRAND_NAME = values.Value(None) + EMAIL_HOST = values.Value(None) + EMAIL_HOST_USER = values.Value(None) + EMAIL_HOST_PASSWORD = values.Value(None) + EMAIL_LOGO_IMG = values.Value(None) + EMAIL_PORT = values.PositiveIntegerValue(None) + EMAIL_USE_TLS = values.BooleanValue(False) + EMAIL_USE_SSL = values.BooleanValue(False) + EMAIL_FROM = values.Value("from@example.com") + + AUTH_USER_MODEL = "core.User" + INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds + + # CORS + CORS_ALLOW_CREDENTIALS = True + CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True) + CORS_ALLOWED_ORIGINS = values.ListValue([]) + CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([]) + + # Sentry + SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None) + + # Collaboration + COLLABORATION_API_URL = values.Value( + None, environ_name="COLLABORATION_API_URL", environ_prefix=None + ) + COLLABORATION_SERVER_SECRET = values.Value( + None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None + ) + COLLABORATION_WS_URL = values.Value( + None, environ_name="COLLABORATION_WS_URL", environ_prefix=None + ) + COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = values.BooleanValue( + False, + environ_name="COLLABORATION_WS_NOT_CONNECTED_READY_ONLY", + environ_prefix=None, + ) + + # Frontend + FRONTEND_THEME = values.Value( + None, environ_name="FRONTEND_THEME", environ_prefix=None + ) + FRONTEND_HOMEPAGE_FEATURE_ENABLED = values.BooleanValue( + default=True, + environ_name="FRONTEND_HOMEPAGE_FEATURE_ENABLED", + environ_prefix=None, + ) + FRONTEND_CSS_URL = values.Value( + None, environ_name="FRONTEND_CSS_URL", environ_prefix=None + ) + + THEME_CUSTOMIZATION_FILE_PATH = values.Value( + os.path.join(BASE_DIR, "impress/configuration/theme/default.json"), + environ_name="THEME_CUSTOMIZATION_FILE_PATH", + environ_prefix=None, + ) + + THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.Value( + 60 * 60 * 24, + environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT", + environ_prefix=None, + ) + + # Posthog + POSTHOG_KEY = values.DictValue( + None, environ_name="POSTHOG_KEY", environ_prefix=None + ) + + # Crisp + CRISP_WEBSITE_ID = values.Value( + None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None + ) + + # Easy thumbnails + THUMBNAIL_EXTENSION = "webp" + THUMBNAIL_TRANSPARENCY_EXTENSION = "webp" + THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default" + THUMBNAIL_ALIASES = {} + + # Celery + CELERY_BROKER_URL = values.Value("redis://redis:6379/0") + CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}) + + # Session + SESSION_ENGINE = "django.contrib.sessions.backends.cache" + SESSION_CACHE_ALIAS = "default" + SESSION_COOKIE_AGE = values.PositiveIntegerValue( + default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None + ) + + # OIDC - Authorization Code Flow + OIDC_CREATE_USER = values.BooleanValue( + default=True, + environ_name="OIDC_CREATE_USER", + ) + OIDC_RP_SIGN_ALGO = values.Value( + "RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None + ) + OIDC_RP_CLIENT_ID = values.Value( + "impress", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None + ) + OIDC_RP_CLIENT_SECRET = values.Value( + None, + environ_name="OIDC_RP_CLIENT_SECRET", + environ_prefix=None, + ) + OIDC_OP_JWKS_ENDPOINT = values.Value( + environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None + ) + OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value( + environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None + ) + OIDC_OP_TOKEN_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None + ) + OIDC_OP_USER_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None + ) + OIDC_OP_LOGOUT_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None + ) + OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue( + {}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None + ) + OIDC_RP_SCOPES = values.Value( + "openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None + ) + LOGIN_REDIRECT_URL = values.Value( + None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None + ) + LOGIN_REDIRECT_URL_FAILURE = values.Value( + None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None + ) + LOGOUT_REDIRECT_URL = values.Value( + None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None + ) + OIDC_USE_NONCE = values.BooleanValue( + default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None + ) + OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue( + default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None + ) + OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue( + default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None + ) + OIDC_STORE_ID_TOKEN = values.BooleanValue( + default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None + ) + OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue( + default=True, + environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION", + environ_prefix=None, + ) + OIDC_USE_PKCE = values.BooleanValue( + default=False, environ_name="OIDC_USE_PKCE", environ_prefix=None + ) + OIDC_PKCE_CODE_CHALLENGE_METHOD = values.Value( + default="S256", + environ_name="OIDC_PKCE_CODE_CHALLENGE_METHOD", + environ_prefix=None, + ) + OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue( + default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None + ) + OIDC_STORE_ACCESS_TOKEN = values.BooleanValue( + default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None + ) + OIDC_STORE_REFRESH_TOKEN = values.BooleanValue( + default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None + ) + OIDC_STORE_REFRESH_TOKEN_KEY = values.Value( + default=None, + environ_name="OIDC_STORE_REFRESH_TOKEN_KEY", + environ_prefix=None, + ) + + # WARNING: Enabling this setting allows multiple user accounts to share the same email + # address. This may cause security issues and is not recommended for production use when + # email is activated as fallback for identification (see previous setting). + OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue( + default=False, + environ_name="OIDC_ALLOW_DUPLICATE_EMAILS", + environ_prefix=None, + ) + + USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue( + default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None + ) + + OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue( + default=values.ListValue( # retrocompatibility + default=["first_name", "last_name"], + environ_name="USER_OIDC_FIELDS_TO_FULLNAME", + environ_prefix=None, + ), + environ_name="OIDC_USERINFO_FULLNAME_FIELDS", + environ_prefix=None, + ) + OIDC_USERINFO_SHORTNAME_FIELD = values.Value( + default=values.Value( # retrocompatibility + default="first_name", + environ_name="USER_OIDC_FIELD_TO_SHORTNAME", + environ_prefix=None, + ), + environ_name="OIDC_USERINFO_SHORTNAME_FIELD", + environ_prefix=None, + ) + + ALLOW_LOGOUT_GET_METHOD = values.BooleanValue( + default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None + ) + + # AI service + AI_FEATURE_ENABLED = values.BooleanValue( + default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None + ) + AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None) + AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) + AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) + AI_ALLOW_REACH_FROM = values.Value( + choices=("public", "authenticated", "restricted"), + default="authenticated", + environ_name="AI_ALLOW_REACH_FROM", + environ_prefix=None, + ) + AI_DOCUMENT_RATE_THROTTLE_RATES = { + "minute": 5, + "hour": 100, + "day": 500, + } + AI_USER_RATE_THROTTLE_RATES = { + "minute": 3, + "hour": 50, + "day": 200, + } + + # Y provider microservice + Y_PROVIDER_API_KEY = values.Value( + environ_name="Y_PROVIDER_API_KEY", + environ_prefix=None, + ) + Y_PROVIDER_API_BASE_URL = values.Value( + environ_name="Y_PROVIDER_API_BASE_URL", + environ_prefix=None, + ) + + # Conversion endpoint + CONVERSION_API_ENDPOINT = values.Value( + default="convert-markdown", + environ_name="CONVERSION_API_ENDPOINT", + environ_prefix=None, + ) + CONVERSION_API_CONTENT_FIELD = values.Value( + default="content", + environ_name="CONVERSION_API_CONTENT_FIELD", + environ_prefix=None, + ) + CONVERSION_API_TIMEOUT = values.Value( + default=30, + environ_name="CONVERSION_API_TIMEOUT", + environ_prefix=None, + ) + CONVERSION_API_SECURE = values.Value( + default=False, + environ_name="CONVERSION_API_SECURE", + environ_prefix=None, + ) + + # Logging + # We want to make it easy to log to console but by default we log production + # to Sentry and don't want to log to console. + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "simple": { + "format": "{asctime} {name} {levelname} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "simple", + }, + }, + # Override root logger to send it to console + "root": { + "handlers": ["console"], + "level": values.Value( + "INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None + ), + }, + "loggers": { + "core": { + "handlers": ["console"], + "level": values.Value( + "INFO", + environ_name="LOGGING_LEVEL_LOGGERS_APP", + environ_prefix=None, + ), + "propagate": False, + }, + "docs.security": { + "handlers": ["console"], + "level": values.Value( + "INFO", + environ_name="LOGGING_LEVEL_LOGGERS_SECURITY", + environ_prefix=None, + ), + "propagate": False, + }, + }, + } + + MALWARE_DETECTION = { + "BACKEND": values.Value( + "lasuite.malware_detection.backends.dummy.DummyBackend", + environ_name="MALWARE_DETECTION_BACKEND", + environ_prefix=None, + ), + "PARAMETERS": values.DictValue( + default={ + "callback_path": "core.malware_detection.malware_detection_callback", + }, + environ_name="MALWARE_DETECTION_PARAMETERS", + environ_prefix=None, + ), + } + + API_USERS_LIST_LIMIT = values.PositiveIntegerValue( + default=5, + environ_name="API_USERS_LIST_LIMIT", + environ_prefix=None, + ) + + # pylint: disable=invalid-name + @property + def ENVIRONMENT(self): + """Environment in which the application is launched.""" + return self.__class__.__name__.lower() + + # pylint: disable=invalid-name + @property + def RELEASE(self): + """ + Return the release information. + + Delegate to the module function to enable easier testing. + """ + return get_release() + + # pylint: disable=invalid-name + @property + def PARLER_LANGUAGES(self): + """ + Return languages for Parler computed from the LANGUAGES and LANGUAGE_CODE settings. + """ + return { + self.SITE_ID: tuple({"code": code} for code, _name in self.LANGUAGES), + "default": { + "fallbacks": [self.LANGUAGE_CODE], + "hide_untranslated": False, + }, + } + + @classmethod + def post_setup(cls): + """Post setup configuration. + This is the place where you can configure settings that require other + settings to be loaded. + """ + super().post_setup() + + # The SENTRY_DSN setting should be available to activate sentry for an environment + if cls.SENTRY_DSN is not None: + sentry_sdk.init( + dsn=cls.SENTRY_DSN, + environment=cls.__name__.lower(), + release=get_release(), + integrations=[DjangoIntegration()], + ) + sentry_sdk.set_tag("application", "backend") + + # Ignore the logs added by the DockerflowMiddleware + ignore_logger("request.summary") + + if ( + cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION + and cls.OIDC_ALLOW_DUPLICATE_EMAILS + ): + raise ValueError( + "Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and " + "OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. " + ) + + +class Build(Base): + """Settings used when the application is built. + + This environment should not be used to run the application. Just to build it with non-blocking + settings. + """ + + SECRET_KEY = values.Value("DummyKey") + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": values.Value( + "whitenoise.storage.CompressedManifestStaticFilesStorage", + environ_name="STORAGES_STATICFILES_BACKEND", + ), + }, + } + + +class Development(Base): + """ + Development environment settings + + We set DEBUG to True and configure the server to respond from all hosts. + """ + + ALLOWED_HOSTS = ["*"] + CORS_ALLOW_ALL_ORIGINS = True + CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"] + DEBUG = True + + SESSION_COOKIE_NAME = "impress_sessionid" + + USE_SWAGGER = True + SESSION_CACHE_ALIAS = "session" + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + }, + "session": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": values.Value( + "redis://redis:6379/2", + environ_name="REDIS_URL", + environ_prefix=None, + ), + "TIMEOUT": values.IntegerValue( + 30, # timeout in seconds + environ_name="CACHES_DEFAULT_TIMEOUT", + environ_prefix=None, + ), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + }, + } + + def __init__(self): + # pylint: disable=invalid-name + self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"] + + +class Test(Base): + """Test environment settings""" + + PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", + ] + USE_SWAGGER = True + + CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True) + + def __init__(self): + # pylint: disable=invalid-name + self.INSTALLED_APPS += ["drf_spectacular_sidecar"] + + +class ContinuousIntegration(Test): + """ + Continuous Integration environment settings + + nota bene: it should inherit from the Test environment. + """ + + +class Production(Base): + """ + Production environment settings + + You must define the ALLOWED_HOSTS environment variable in Production + configuration (and derived configurations): + ALLOWED_HOSTS=["foo.com", "foo.fr"] + """ + + # Security + # Add allowed host from environment variables. + # The machine hostname is added by default, + # it makes the application pingable by a load balancer on the same machine by example + ALLOWED_HOSTS = [ + *values.ListValue([], environ_name="ALLOWED_HOSTS"), + gethostbyname(gethostname()), + ] + CSRF_TRUSTED_ORIGINS = values.ListValue([]) + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + + # SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest + # object when your application is behind a reverse proxy. + # + # Keep this SECURE_PROXY_SSL_HEADER configuration only if : + # - your Django app is behind a proxy. + # - your proxy strips the X-Forwarded-Proto header from all incoming requests + # - Your proxy sets the X-Forwarded-Proto header and sends it to Django + # + # In other cases, you should comment the following line to avoid security issues. + # SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SECURE_HSTS_SECONDS = 60 + SECURE_HSTS_PRELOAD = True + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_SSL_REDIRECT = True + SECURE_REDIRECT_EXEMPT = [ + "^__lbheartbeat__", + "^__heartbeat__", + ] + + # Modern browsers require to have the `secure` attribute on cookies with `Samesite=none` + CSRF_COOKIE_SECURE = True + SESSION_COOKIE_SECURE = True + + # Privacy + SECURE_REFERRER_POLICY = "same-origin" + + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": values.Value( + "redis://redis:6379/1", + environ_name="REDIS_URL", + environ_prefix=None, + ), + "TIMEOUT": values.IntegerValue( + 30, # timeout in seconds + environ_name="CACHES_DEFAULT_TIMEOUT", + environ_prefix=None, + ), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + "KEY_PREFIX": values.Value( + "docs", + environ_name="CACHES_KEY_PREFIX", + environ_prefix=None, + ), + }, + } + + +class Feature(Production): + """ + Feature environment settings + + nota bene: it should inherit from the Production environment. + """ + + +class Staging(Production): + """ + Staging environment settings + + nota bene: it should inherit from the Production environment. + """ + + +class PreProduction(Production): + """ + Pre-production environment settings + + nota bene: it should inherit from the Production environment. + """ + + +class Demo(Production): + """ + Demonstration environment settings + + nota bene: it should inherit from the Production environment. + """ + + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } diff --git a/submissions/devoteam/docs/src/backend/impress/urls.py b/submissions/devoteam/docs/src/backend/impress/urls.py new file mode 100644 index 00000000..2c5964d4 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/impress/urls.py @@ -0,0 +1,48 @@ +"""URL configuration for the impress project""" + +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.urls import include, path, re_path + +from drf_spectacular.views import ( + SpectacularJSONAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("core.urls")), +] + +if settings.DEBUG: + urlpatterns = ( + urlpatterns + + staticfiles_urlpatterns() + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + ) + + +if settings.USE_SWAGGER or settings.DEBUG: + urlpatterns += [ + path( + f"api/{settings.API_VERSION}/swagger.json", + SpectacularJSONAPIView.as_view( + api_version=settings.API_VERSION, + urlconf="core.urls", + ), + name="client-api-schema", + ), + path( + f"api/{settings.API_VERSION}/swagger/", + SpectacularSwaggerView.as_view(url_name="client-api-schema"), + name="swagger-ui-schema", + ), + re_path( + f"api/{settings.API_VERSION}/redoc/", + SpectacularRedocView.as_view(url_name="client-api-schema"), + name="redoc-schema", + ), + ] diff --git a/submissions/devoteam/docs/src/backend/impress/wsgi.py b/submissions/devoteam/docs/src/backend/impress/wsgi.py new file mode 100644 index 00000000..6076021c --- /dev/null +++ b/submissions/devoteam/docs/src/backend/impress/wsgi.py @@ -0,0 +1,17 @@ +""" +WSGI config for the impress project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +""" + +import os + +from configurations.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings") +os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + +application = get_wsgi_application() diff --git a/submissions/devoteam/docs/src/backend/locale/br_FR/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/br_FR/LC_MESSAGES/django.mo new file mode 100644 index 00000000..e183ab7a Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/br_FR/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/br_FR/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/br_FR/LC_MESSAGES/django.po new file mode 100644 index 00000000..35563270 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/br_FR/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Breton\n" +"Language: br_FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=5; plural=(n%10==1 && (n%100!=11 || n%100!=71 || n%100!=91) ? 0 : n%10==2 && (n%100!=12 || n%100!=72 || n%100!=92) ? 1 : ((n%10>=3 && n%10<=4) || n%10==9) && ((n%100 < 10 || n%100 > 19) || (n%100 < 70 || n%100 > 79) || (n%100 < 90 || n%100 > 99)) ? 2 : (n!=0 && n%1;\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: br-FR\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "Titouroù personel" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "Aotreoù" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "Deiziadoù a-bouez" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "Gwezennadur" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "Titl" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "Me eo an aozer" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "Sinedoù" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "Ur restr nevez a zo bet krouet ganeoc'h!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "Korf" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "Doare korf" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "Stumm" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "eilenn {title}" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "Bugel kentañ" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "Bugel diwezhañ" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "Kleiz" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "Dehoù" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "Lenner" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "Merour" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "Perc'henn" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "Publik" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "id" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "krouet d'ar/al" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "hizivaet d'ar/al" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "" + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "" + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "anv klok" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "anv berr" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "yezh" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "" + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "" + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "trevnad" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "" + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "" + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "" + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "implijer" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "implijerien" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "titl" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "" + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "" + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "" + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "" + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "" + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "css" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "publik" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "" + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "Patrom" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "Patromoù" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "" + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "" + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "" + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "Digeriñ" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr "" + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr "" + diff --git a/submissions/devoteam/docs/src/backend/locale/cn_CN/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/cn_CN/LC_MESSAGES/django.mo new file mode 100644 index 00000000..206c5d6f Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/cn_CN/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/cn_CN/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/cn_CN/LC_MESSAGES/django.po new file mode 100644 index 00000000..ce36daa1 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/cn_CN/LC_MESSAGES/django.po @@ -0,0 +1,399 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-04-04 13:46+0000\n" +"PO-Revision-Date: 2025-04-16 16:32\n" +"Last-Translator: \n" +"Language-Team: Chinese Simplified\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: zh-CN\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "个人信息" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "权限" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "重要日期" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "树状结构" + +#: build/lib/core/api/filters.py:16 core/api/filters.py:16 +msgid "Title" +msgstr "标题" + +#: build/lib/core/api/filters.py:30 core/api/filters.py:30 +msgid "Creator is me" +msgstr "创建者是我" + +#: build/lib/core/api/filters.py:33 core/api/filters.py:33 +msgid "Favorite" +msgstr "收藏" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "已为您创建了一份新文档!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "您已被授予新文档的所有权:" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "正文" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "正文类型" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "格式" + +#: build/lib/core/api/viewsets.py:944 core/api/viewsets.py:944 +#, python-brace-format +msgid "copy of {title}" +msgstr "{title} 的副本" + +#: build/lib/core/authentication/backends.py:61 +#: core/authentication/backends.py:61 +msgid "Invalid response format or token verification failed" +msgstr "响应格式无效或令牌验证失败" + +#: build/lib/core/authentication/backends.py:108 +#: core/authentication/backends.py:108 +msgid "User account is disabled" +msgstr "用户账户已被禁用" + +#: build/lib/core/enums.py:35 core/enums.py:35 +msgid "First child" +msgstr "第一个子项" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "Last child" +msgstr "最后一个子项" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "First sibling" +msgstr "第一个同级项" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "Last sibling" +msgstr "最后一个同级项" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Left" +msgstr "左" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Right" +msgstr "右" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "阅读者" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "编辑者" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "超级管理员" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "所有者" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "受限的" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "已验证" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "公开" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "id" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "记录的主密钥为 UUID" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "创建时间" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "记录的创建日期和时间" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "更新时间" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "记录的最后更新时间" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "未找到具有该 sub 的用户,但该邮箱已关联到一个注册用户。" + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "请输入有效的 sub。该值只能包含字母、数字及 @/./+/-/_/: 字符。" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "sub" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "必填。最多 255 个字符,仅允许字母、数字及 @/./+/-/_/: 字符。" + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "全名" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "简称" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "身份电子邮件地址" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "管理员电子邮件地址" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "语言" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "用户希望看到的界面语言。" + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "用户查看时间希望的时区。" + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "设备" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "用户是设备还是真实用户。" + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "员工状态" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "用户是否可以登录该管理员站点。" + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "激活" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。" + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "用户" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "个用户" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1154 +#: core/models.py:470 core/models.py:1154 +msgid "title" +msgstr "标题" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "摘要" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "文档" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "个文档" + +#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532 +#: core/models.py:872 +msgid "Untitled Document" +msgstr "未命名文档" + +#: build/lib/core/models.py:907 core/models.py:907 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "{name} 与您共享了一个文档!" + +#: build/lib/core/models.py:911 core/models.py:911 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "{name} 邀请您以“{role}”角色访问以下文档:" + +#: build/lib/core/models.py:917 core/models.py:917 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} 与您共享了一个文档:{title}" + +#: build/lib/core/models.py:1015 core/models.py:1015 +msgid "Document/user link trace" +msgstr "文档/用户链接跟踪" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link traces" +msgstr "个文档/用户链接跟踪" + +#: build/lib/core/models.py:1022 core/models.py:1022 +msgid "A link trace already exists for this document/user." +msgstr "此文档/用户的链接跟踪已存在。" + +#: build/lib/core/models.py:1045 core/models.py:1045 +msgid "Document favorite" +msgstr "文档收藏" + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorites" +msgstr "文档收藏夹" + +#: build/lib/core/models.py:1052 core/models.py:1052 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "该文档已被同一用户的收藏关系实例关联。" + +#: build/lib/core/models.py:1074 core/models.py:1074 +msgid "Document/user relation" +msgstr "文档/用户关系" + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relations" +msgstr "文档/用户关系集" + +#: build/lib/core/models.py:1081 core/models.py:1081 +msgid "This user is already in this document." +msgstr "该用户已在此文档中。" + +#: build/lib/core/models.py:1087 core/models.py:1087 +msgid "This team is already in this document." +msgstr "该团队已在此文档中。" + +#: build/lib/core/models.py:1093 build/lib/core/models.py:1241 +#: core/models.py:1093 core/models.py:1241 +msgid "Either user or team must be set, not both." +msgstr "必须设置用户或团队之一,不能同时设置两者。" + +#: build/lib/core/models.py:1155 core/models.py:1155 +msgid "description" +msgstr "说明" + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "code" +msgstr "代码" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "css" +msgstr "css" + +#: build/lib/core/models.py:1159 core/models.py:1159 +msgid "public" +msgstr "公开" + +#: build/lib/core/models.py:1161 core/models.py:1161 +msgid "Whether this template is public for anyone to use." +msgstr "该模板是否公开供任何人使用。" + +#: build/lib/core/models.py:1167 core/models.py:1167 +msgid "Template" +msgstr "模板" + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Templates" +msgstr "模板" + +#: build/lib/core/models.py:1222 core/models.py:1222 +msgid "Template/user relation" +msgstr "模板/用户关系" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relations" +msgstr "模板/用户关系集" + +#: build/lib/core/models.py:1229 core/models.py:1229 +msgid "This user is already in this template." +msgstr "该用户已在此模板中。" + +#: build/lib/core/models.py:1235 core/models.py:1235 +msgid "This team is already in this template." +msgstr "该团队已在此模板中。" + +#: build/lib/core/models.py:1258 core/models.py:1258 +msgid "email address" +msgstr "电子邮件地址" + +#: build/lib/core/models.py:1277 core/models.py:1277 +msgid "Document invitation" +msgstr "文档邀请" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitations" +msgstr "文档邀请" + +#: build/lib/core/models.py:1298 core/models.py:1298 +msgid "This email is already associated to a registered user." +msgstr "此电子邮件已经与现有注册用户关联。" + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "徽标邮件" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "打开" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr " Docs——您的全新必备工具,帮助团队组织、共享和协作处理文档。 " + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr " 由 %(brandname)s 倾力打造。 " diff --git a/submissions/devoteam/docs/src/backend/locale/de_DE/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/de_DE/LC_MESSAGES/django.mo new file mode 100644 index 00000000..50585af9 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/de_DE/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/de_DE/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/de_DE/LC_MESSAGES/django.po new file mode 100644 index 00000000..f3f1c03c --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/de_DE/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: German\n" +"Language: de_DE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "Persönliche Daten" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "Berechtigungen" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "Wichtige Daten" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "Baumstruktur" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "Titel" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "Ersteller bin ich" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "Favorit" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "Sie sind Besitzer eines neuen Dokuments:" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "Inhalt" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "Typ" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "Format" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "Kopie von {title}" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "Erstes Unterelement" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "Letztes Unterelement" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "Erstes Nebenelement" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "Letztes Nebenelement" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "Links" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "Rechts" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "Lesen" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "Bearbeiten" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "Administrator" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "Besitzer" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "Beschränkt" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "Authentifiziert" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "Öffentlich" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "id" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "primärer Schlüssel für den Datensatz als UUID" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "Erstellt" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "Aktualisiert" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "Wir konnten keinen Benutzer mit diesem Abo finden, aber die E-Mail-Adresse ist bereits einem registrierten Benutzer zugeordnet." + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten." + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "unter" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:" + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "Name" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "Kurzbezeichnung" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "Identitäts-E-Mail-Adresse" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "Admin E-Mail-Adresse" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "Sprache" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte." + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte." + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "Gerät" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist." + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "Status des Teammitgliedes" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann." + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "aktiviert" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen." + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "Benutzer" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "Benutzer" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "Titel" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "Auszug" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "Dokument" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "Dokumente" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "Unbenanntes Dokument" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "{name} hat ein Dokument mit Ihnen geteilt!" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "Dokument/Benutzer Linkverfolgung" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "Dokument/Benutzer Linkverfolgung" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden." + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "Dokumentenfavorit" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "Dokumentfavoriten" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden." + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "Dokument/Benutzerbeziehung" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "Dokument/Benutzerbeziehungen" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument." + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "Dieses Team befindet sich bereits in diesem Dokument." + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides." + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "Beschreibung" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "Code" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "CSS" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "öffentlich" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "Ob diese Vorlage für jedermann öffentlich ist." + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "Vorlage" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "Vorlagen" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "Vorlage/Benutzer-Beziehung" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "Vorlage/Benutzerbeziehungen" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "Dieser Benutzer ist bereits in dieser Vorlage." + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "Dieses Team ist bereits in diesem Template." + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "E-Mail-Adresse" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "Einladung zum Dokument" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "Dokumenteinladungen" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet." + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "Logo-E-Mail" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "Öffnen" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. " + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr " Erstellt von %(brandname)s " + diff --git a/submissions/devoteam/docs/src/backend/locale/en_US/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/en_US/LC_MESSAGES/django.mo new file mode 100644 index 00000000..d8e975a5 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/en_US/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/en_US/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/en_US/LC_MESSAGES/django.po new file mode 100644 index 00000000..dfc56fb6 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/en_US/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: English\n" +"Language: en_US\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: en\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "" + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "" + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "" + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "" + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "" + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "" + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "" + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "" + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "" + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "" + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "" + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "" + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "" + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "" + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "" + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "" + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr "" + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr "" + diff --git a/submissions/devoteam/docs/src/backend/locale/es_ES/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/es_ES/LC_MESSAGES/django.mo new file mode 100644 index 00000000..b6542288 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/es_ES/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/es_ES/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/es_ES/LC_MESSAGES/django.po new file mode 100644 index 00000000..c85de6cd --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/es_ES/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "Información Personal" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "Permisos" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "Fechas importantes" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "Estructura en árbol" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "Título" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "Yo soy el creador" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "Favorito" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "¡Un nuevo documento se ha creado por ti!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "Se le ha concedido la propiedad de un nuevo documento :" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "Cuerpo" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "Tipo de Cuerpo" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "Formato" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "copia de {title}" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "Primer nodo" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "Último nodo" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "Primera relación" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "Última relación" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "Izquierda" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "Derecha" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "Lector" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "Editor" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "Administrador" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "Propietario" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "Restringido" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "Autentificado" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "Público" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "id" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "clave primaria para el registro como UUID" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "creado el" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "fecha y hora en la que se creó un registro" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "actualizado el" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "fecha y hora en la que un registro fue actualizado por última vez" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "No se ha podido encontrar un usuario con este sub (UUID), pero el correo electrónico ya está asociado con un usuario." + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "Introduzca un sub (UUID) válido. Este valor solo puede contener letras, números y los siguientes caracteres @/./+/-/_/:" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "sub (UUID)" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "Requerido. 255 caracteres o menos. Letras, números y los siguientes caracteres @/./+/-/_/: solamente." + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "nombre completo" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "nombre abreviado" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "correo electrónico de identidad" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "correo electrónico del administrador" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "idioma" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "El idioma en el que el usuario desea ver la interfaz." + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "La zona horaria en la que el usuario quiere ver los tiempos." + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "dispositivo" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "Si el usuario es un dispositivo o un usuario real." + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "rol en el equipo" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "Si el usuario puede iniciar sesión en esta página web de administración." + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "activo" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas." + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "usuario" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "usuarios" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "título" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "resumen" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "Documento" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "Documentos" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "Documento sin título" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "¡{name} ha compartido un documento contigo!" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} ha compartido un documento contigo: {title}" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "Traza del enlace de documento/usuario" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "Trazas del enlace de documento/usuario" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "Ya existe una traza de enlace para este documento/usuario." + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "Documento favorito" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "Documentos favoritos" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "Este documento ya ha sido marcado como favorito por el usuario." + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "Relación documento/usuario" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "Relaciones documento/usuario" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "Este usuario ya forma parte del documento." + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "Este equipo ya forma parte del documento." + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "Debe establecerse un usuario o un equipo, no ambos." + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "descripción" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "código" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "css" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "público" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "Si esta plantilla es pública para que cualquiera la utilice." + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "Plantilla" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "Plantillas" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "Relación plantilla/usuario" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "Relaciones plantilla/usuario" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "Este usuario ya forma parte de la plantilla." + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "Este equipo ya se encuentra en esta plantilla." + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "dirección de correo electrónico" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "Invitación al documento" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "Invitaciones a documentos" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "Este correo electrónico está asociado a un usuario registrado." + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "Logo de correo electrónico" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "Abrir" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr "Docs, su nueva herramienta esencial para organizar, compartir y colaborar en sus documentos como equipo." + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr " Presentado por %(brandname)s " + diff --git a/submissions/devoteam/docs/src/backend/locale/fr_FR/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/fr_FR/LC_MESSAGES/django.mo new file mode 100644 index 00000000..d011aaf7 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/fr_FR/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/fr_FR/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/fr_FR/LC_MESSAGES/django.po new file mode 100644 index 00000000..9e76e6a3 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/fr_FR/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: French\n" +"Language: fr_FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "Infos Personnelles" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "Permissions" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "Dates importantes" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "Arborescence" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "Titre" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "Je suis l'auteur" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "Favoris" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "Un nouveau document a été créé pour vous !" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "Vous avez été déclaré propriétaire d'un nouveau document :" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "Corps" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "Type de corps" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "Format" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "copie de {title}" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "Premier enfant" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "Dernier enfant" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "Premier frère ou sœur" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "Dernière relation" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "Gauche" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "Droite" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "Lecteur" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "Éditeur" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "Administrateur" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "Propriétaire" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "Restreint" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "Authentifié" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "Public" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "identifiant/id" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "clé primaire pour l'enregistrement en tant que UUID" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "créé le" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "date et heure de création de l'enregistrement" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "mis à jour le" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "date et heure de la dernière mise à jour de l'enregistrement" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail est déjà associé à un utilisateur enregistré." + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "Saisissez un sous-groupe valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement." + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "sous-groupe" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_/: uniquement." + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "nom complet" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "nom court" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "adresse e-mail d'identité" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "adresse e-mail de l'administrateur" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "langue" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "La langue dans laquelle l'utilisateur veut voir l'interface." + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures." + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "appareil" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "Si l'utilisateur est un appareil ou un utilisateur réel." + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "statut d'équipe" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "Si l'utilisateur peut se connecter à ce site d'administration." + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "actif" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes." + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "utilisateur" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "utilisateurs" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "titre" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "extrait" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "Document" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "Documents" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "Document sans titre" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "{name} a partagé un document avec vous!" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} a partagé un document avec vous : {title}" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "Trace du lien document/utilisateur" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "Traces du lien document/utilisateur" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "Une trace de lien existe déjà pour ce document/utilisateur." + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "Document favori" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "Documents favoris" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "Ce document est déjà un favori de cet utilisateur." + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "Relation document/utilisateur" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "Relations document/utilisateur" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "Cet utilisateur est déjà dans ce document." + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "Cette équipe est déjà dans ce document." + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux." + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "description" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "code" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "CSS" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "public" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "Si ce modèle est public, utilisable par n'importe qui." + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "Modèle" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "Modèles" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "Relation modèle/utilisateur" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "Relations modèle/utilisateur" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "Cet utilisateur est déjà dans ce modèle." + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "Cette équipe est déjà modèle." + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "adresse e-mail" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "Invitation à un document" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "Invitations à un document" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "Cette adresse email est déjà associée à un utilisateur inscrit." + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "Logo de l'e-mail" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "Ouvrir" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. " + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr " Proposé par %(brandname)s " + diff --git a/submissions/devoteam/docs/src/backend/locale/it_IT/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/it_IT/LC_MESSAGES/django.mo new file mode 100644 index 00000000..dda10411 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/it_IT/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/it_IT/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/it_IT/LC_MESSAGES/django.po new file mode 100644 index 00000000..820a9438 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/it_IT/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Italian\n" +"Language: it_IT\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: it\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "Informazioni personali" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "Permessi" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "Date importanti" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "Struttura ad albero" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "Titolo" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "Il creatore sono io" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "Preferiti" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "Un nuovo documento è stato creato a tuo nome!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "Sei ora proprietario di un nuovo documento:" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "Corpo" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "Formato" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "copia di {title}" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "Sinistra" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "Destra" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "Lettore" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "Editor" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "Amministratore" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "Proprietario" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "Limitato" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "Autenticato" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "Pubblico" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "Id" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "chiave primaria per il record come UUID" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "creato il" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "data e ora in cui è stato creato un record" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "aggiornato il" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "data e ora in cui l’ultimo record è stato aggiornato" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "" + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "Richiesto. 255 caratteri o meno. Solo lettere, numeri e @/./+/-/_/: caratteri." + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "nome completo" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "nome" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "indirizzo email di identità" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "Indirizzo email dell'amministratore" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "lingua" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "La lingua in cui l'utente vuole vedere l'interfaccia." + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "Il fuso orario in cui l'utente vuole vedere gli orari." + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "dispositivo" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "Se l'utente è un dispositivo o un utente reale." + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "stato del personale" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "Indica se l'utente può accedere a questo sito amministratore." + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "attivo" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account." + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "utente" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "utenti" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "titolo" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "Documento" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "Documenti" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "Documento senza titolo" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "{name} ha condiviso un documento con te!" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} ha condiviso un documento con te: {title}" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "" + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "Documento preferito" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "Documenti preferiti" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "" + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "Questo utente è già presente in questo documento." + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "Questo team è già presente in questo documento." + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "" + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "descrizione" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "code" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "css" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "pubblico" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "Indica se questo modello è pubblico per chiunque." + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "Modello" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "Modelli" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "Questo utente è già in questo modello." + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "Questo team è già in questo modello." + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "indirizzo e-mail" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "Invito al documento" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "Inviti al documento" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "Questa email è già associata a un utente registrato." + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "Logo e-mail" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "Apri" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr "" + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr "" + diff --git a/submissions/devoteam/docs/src/backend/locale/nl_NL/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/nl_NL/LC_MESSAGES/django.mo new file mode 100644 index 00000000..f5255f96 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/nl_NL/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/nl_NL/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/nl_NL/LC_MESSAGES/django.po new file mode 100644 index 00000000..f52df040 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/nl_NL/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Dutch\n" +"Language: nl_NL\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: nl\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "Persoonlijke informatie" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "Toestemmingen" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "Belangrijke datums" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "Document structuur" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "Titel" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "Ik ben Eigenaar" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "Favoriete" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "Een nieuw document was gecreëerd voor u!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "U heeft eigenaarschap van een nieuw document:" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "Text" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "Text type" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "Formaat" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "kopie van {title}" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "Eerste node" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "Laatste node" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "Eerste naaste" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "Laatste naaste" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "Links" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "Rechts" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "Lezer" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "Bewerker" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "Administrator" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "Eigenaar" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "Niet toegestaan" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "Geauthenticeerd" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "Publiek" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "id" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "primaire sleutel voor dossier als UUID" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "gemaakt op" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "datum en tijd wanneer dossier was gecreëerd" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "Laatst gewijzigd op" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "datum en tijd waarop dossier laatst was gewijzigd" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker." + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr ".Geef een valide id. De waarde mag alleen letters, nummers en @/./.+/-/_: karakters bevatten." + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "id" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "Verplicht. 255 karakters of minder. Alleen letters, nummers en @/./+/-/_/: karakters zijn toegestaan." + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "volledige naam" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "gebruikersnaam" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "identiteit email adres" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "admin email adres" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "taal" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "De taal waarin de gebruiker de interface wilt zien." + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "De tijdzone waarin de gebruiker de tijden wilt zien." + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "apparaat" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "Of de gebruiker een apparaat is of een echte gebruiker." + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "beheerder status" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "Of de gebruiker kan inloggen in het admin gedeelte." + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "actief" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten." + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "gebruiker" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "gebruikers" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "titel" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "uittreksel" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "Document" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "Documenten" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "Naamloos Document" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "{name} heeft een document met gedeeld!" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} heeft een document met u gedeeld: {title}" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "Document/gebruiker url" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "Document/gebruiker url" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "Een url bestaat al voor dit document/deze gebruiker." + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "Document favoriet" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "Document favorieten" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker." + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "Document/gebruiker relatie" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "Document/gebruiker relaties" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "De gebruiker is al in dit document." + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "Het team is al in dit document." + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "Een gebruiker of team moet gekozen worden, maar niet beide." + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "omschrijving" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "code" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "css" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "publiek" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "Of dit template als publiek is en door iedereen te gebruiken is." + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "Template" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "Templates" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "Template/gebruiker relatie" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "Template/gebruiker relaties" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "De gebruiker bestaat al in dit template." + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "Het team bestaat al in dit template." + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "email adres" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "Document uitnodiging" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "Document uitnodigingen" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker." + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "Logo email" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "Open" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. " + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr " Geleverd door %(brandname)s " + diff --git a/submissions/devoteam/docs/src/backend/locale/pt_PT/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/pt_PT/LC_MESSAGES/django.mo new file mode 100644 index 00000000..ff5124b8 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/pt_PT/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/pt_PT/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/pt_PT/LC_MESSAGES/django.po new file mode 100644 index 00000000..bb9e9751 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/pt_PT/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"Language: pt_PT\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "" + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "" + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "" + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "" + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "" + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "" + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "" + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "" + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "" + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "" + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "" + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "" + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "" + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "" + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "" + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "" + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr "" + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr "" + diff --git a/submissions/devoteam/docs/src/backend/locale/sl_SI/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/sl_SI/LC_MESSAGES/django.mo new file mode 100644 index 00000000..14ba9803 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/sl_SI/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/sl_SI/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/sl_SI/LC_MESSAGES/django.po new file mode 100644 index 00000000..5605507b --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/sl_SI/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Slovenian\n" +"Language: sl_SI\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: sl\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "Osebni podatki" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "Dovoljenja" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "Pomembni datumi" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "Drevesna struktura" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "Naslov" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "Ustvaril sem jaz" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "Priljubljena" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "Nov dokument je bil ustvarjen v vašem imenu!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "Telo" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "Vrsta telesa" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "Oblika" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "Prvi otrok" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "Zadnji otrok" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "Prvi brat in sestra" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "Zadnji brat in sestra" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "Levo" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "Desno" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "Bralec" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "Urednik" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "Skrbnik" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "Lastnik" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "Omejeno" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "Preverjeno" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "Javno" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "primarni ključ za zapis kot UUID" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "ustvarjen na" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "datum in čas, ko je bil zapis ustvarjen" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "posodobljeno dne" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "datum in čas, ko je bil zapis nazadnje posodobljen" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "Nismo mogli najti uporabnika s tem sub, vendar je e-poštni naslov že povezan z registriranim uporabnikom." + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "Vnesite veljavno sub. Ta vrednost lahko vsebuje samo črke, številke in znake @/./+/-/_/:." + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "Obvezno. 255 znakov ali manj. Samo črke, številke in znaki @/./+/-/_/: ." + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "polno ime" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "kratko ime" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "elektronski naslov identitete" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "elektronski naslov skrbnika" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "jezik" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "Jezik, v katerem uporabnik želi videti vmesnik." + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "Časovni pas, v katerem želi uporabnik videti uro." + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "naprava" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "Ali je uporabnik naprava ali pravi uporabnik." + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "kadrovski status" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "Ali se uporabnik lahko prijavi na to skrbniško mesto." + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "aktivni" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro." + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "uporabnik" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "uporabniki" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "naslov" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "odlomek" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "Dokument" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "Dokumenti" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "Dokument brez naslova" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "{name} je delil dokument z vami!" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} je delil dokument z vami: {title}" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "Dokument/sled povezave uporabnika" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "Sledi povezav dokumenta/uporabnika" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "Za ta dokument/uporabnika že obstaja sled povezave." + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "Priljubljeni dokument" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "Priljubljeni dokumenti" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika." + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "Odnos dokument/uporabnik" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "Odnosi dokument/uporabnik" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "Ta uporabnik je že v tem dokumentu." + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "Ta ekipa je že v tem dokumentu." + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega." + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "opis" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "koda" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "css" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "javno" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "Ali je ta predloga javna za uporabo." + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "Predloga" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "Predloge" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "Odnos predloga/uporabnik" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "Odnosi med predlogo in uporabnikom" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "Ta uporabnik je že v tej predlogi." + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "Ta ekipa je že v tej predlogi." + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "elektronski naslov" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "Vabilo na dokument" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "Vabila na dokument" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom." + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "E-pošta z logotipom" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "Odpri" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr " Dokumenti, vaše novo bistveno orodje za organiziranje, skupno rabo in skupinsko sodelovanje pri dokumentih. " + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr " Pod okriljem %(brandname)s " + diff --git a/submissions/devoteam/docs/src/backend/locale/sv_SE/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/sv_SE/LC_MESSAGES/django.mo new file mode 100644 index 00000000..ee9a801e Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/sv_SE/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/sv_SE/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/sv_SE/LC_MESSAGES/django.po new file mode 100644 index 00000000..dd2f89a0 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/sv_SE/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Swedish\n" +"Language: sv_SE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: sv-SE\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "Personuppgifter" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "Behörigheter" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "Viktiga datum" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "Titel" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "Skaparen är jag" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "Favoriter" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "Ett nytt dokument skapades åt dig!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "Du har beviljats äganderätt till ett nytt dokument:" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "Format" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "Administratör" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "Publik" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "" + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "" + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "" + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "" + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "" + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "" + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "aktiv" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "" + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "" + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "" + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "" + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "" + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "" + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "" + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "" + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "" + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "e-postadress" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "Bjud in dokument" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "Inbjudningar dokument" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "Denna e-postadress är redan associerad med en registrerad användare." + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "Logotyp e-post" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "Öppna" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr "" + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr "" + diff --git a/submissions/devoteam/docs/src/backend/locale/tr_TR/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/tr_TR/LC_MESSAGES/django.mo new file mode 100644 index 00000000..7bb53f33 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/tr_TR/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/tr_TR/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/tr_TR/LC_MESSAGES/django.po new file mode 100644 index 00000000..80217d7f --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/tr_TR/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"Language: tr_TR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "" + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "" + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "" + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "" + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "" + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "" + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "" + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "" + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "" + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "" + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "" + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "" + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "" + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "" + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "" + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "" + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr "" + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr "" + diff --git a/submissions/devoteam/docs/src/backend/locale/zh_CN/LC_MESSAGES/django.mo b/submissions/devoteam/docs/src/backend/locale/zh_CN/LC_MESSAGES/django.mo new file mode 100644 index 00000000..6ab6d9d0 Binary files /dev/null and b/submissions/devoteam/docs/src/backend/locale/zh_CN/LC_MESSAGES/django.mo differ diff --git a/submissions/devoteam/docs/src/backend/locale/zh_CN/LC_MESSAGES/django.po b/submissions/devoteam/docs/src/backend/locale/zh_CN/LC_MESSAGES/django.po new file mode 100644 index 00000000..bfd8f284 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/locale/zh_CN/LC_MESSAGES/django.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"Project-Id-Version: lasuite-docs\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-22 12:09+0000\n" +"PO-Revision-Date: 2025-05-22 14:16\n" +"Last-Translator: \n" +"Language-Team: Chinese Simplified\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" +"X-Crowdin-Language: zh-CN\n" +"X-Crowdin-File: backend-impress.pot\n" +"X-Crowdin-File-ID: 18\n" + +#: build/lib/core/admin.py:37 core/admin.py:37 +msgid "Personal info" +msgstr "个人信息" + +#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50 +#: core/admin.py:138 +msgid "Permissions" +msgstr "权限" + +#: build/lib/core/admin.py:62 core/admin.py:62 +msgid "Important dates" +msgstr "重要日期" + +#: build/lib/core/admin.py:148 core/admin.py:148 +msgid "Tree structure" +msgstr "树状结构" + +#: build/lib/core/api/filters.py:47 core/api/filters.py:47 +msgid "Title" +msgstr "标题" + +#: build/lib/core/api/filters.py:61 core/api/filters.py:61 +msgid "Creator is me" +msgstr "创建者是我" + +#: build/lib/core/api/filters.py:64 core/api/filters.py:64 +msgid "Favorite" +msgstr "收藏" + +#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446 +msgid "A new document was created on your behalf!" +msgstr "已为您创建了一份新文档!" + +#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450 +msgid "You have been granted ownership of a new document:" +msgstr "您已被授予新文档的所有权:" + +#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586 +msgid "Body" +msgstr "正文" + +#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589 +msgid "Body type" +msgstr "正文类型" + +#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595 +msgid "Format" +msgstr "格式" + +#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967 +#, python-brace-format +msgid "copy of {title}" +msgstr "{title} 的副本" + +#: build/lib/core/enums.py:36 core/enums.py:36 +msgid "First child" +msgstr "第一个子项" + +#: build/lib/core/enums.py:37 core/enums.py:37 +msgid "Last child" +msgstr "最后一个子项" + +#: build/lib/core/enums.py:38 core/enums.py:38 +msgid "First sibling" +msgstr "第一个同级项" + +#: build/lib/core/enums.py:39 core/enums.py:39 +msgid "Last sibling" +msgstr "最后一个同级项" + +#: build/lib/core/enums.py:40 core/enums.py:40 +msgid "Left" +msgstr "左" + +#: build/lib/core/enums.py:41 core/enums.py:41 +msgid "Right" +msgstr "右" + +#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56 +#: core/models.py:63 +msgid "Reader" +msgstr "阅读者" + +#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57 +#: core/models.py:64 +msgid "Editor" +msgstr "编辑者" + +#: build/lib/core/models.py:65 core/models.py:65 +msgid "Administrator" +msgstr "超级管理员" + +#: build/lib/core/models.py:66 core/models.py:66 +msgid "Owner" +msgstr "所有者" + +#: build/lib/core/models.py:77 core/models.py:77 +msgid "Restricted" +msgstr "受限的" + +#: build/lib/core/models.py:81 core/models.py:81 +msgid "Authenticated" +msgstr "已验证" + +#: build/lib/core/models.py:83 core/models.py:83 +msgid "Public" +msgstr "公开" + +#: build/lib/core/models.py:154 core/models.py:154 +msgid "id" +msgstr "id" + +#: build/lib/core/models.py:155 core/models.py:155 +msgid "primary key for the record as UUID" +msgstr "记录的主密钥为 UUID" + +#: build/lib/core/models.py:161 core/models.py:161 +msgid "created on" +msgstr "创建时间" + +#: build/lib/core/models.py:162 core/models.py:162 +msgid "date and time at which a record was created" +msgstr "记录的创建日期和时间" + +#: build/lib/core/models.py:167 core/models.py:167 +msgid "updated on" +msgstr "更新时间" + +#: build/lib/core/models.py:168 core/models.py:168 +msgid "date and time at which a record was last updated" +msgstr "记录的最后更新时间" + +#: build/lib/core/models.py:204 core/models.py:204 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "未找到具有该 sub 的用户,但该邮箱已关联到一个注册用户。" + +#: build/lib/core/models.py:217 core/models.py:217 +msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." +msgstr "请输入有效的 sub。该值只能包含字母、数字及 @/./+/-/_/: 字符。" + +#: build/lib/core/models.py:223 core/models.py:223 +msgid "sub" +msgstr "sub" + +#: build/lib/core/models.py:225 core/models.py:225 +msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." +msgstr "必填。最多 255 个字符,仅允许字母、数字及 @/./+/-/_/: 字符。" + +#: build/lib/core/models.py:234 core/models.py:234 +msgid "full name" +msgstr "全名" + +#: build/lib/core/models.py:235 core/models.py:235 +msgid "short name" +msgstr "简称" + +#: build/lib/core/models.py:237 core/models.py:237 +msgid "identity email address" +msgstr "身份电子邮件地址" + +#: build/lib/core/models.py:242 core/models.py:242 +msgid "admin email address" +msgstr "管理员电子邮件地址" + +#: build/lib/core/models.py:249 core/models.py:249 +msgid "language" +msgstr "语言" + +#: build/lib/core/models.py:250 core/models.py:250 +msgid "The language in which the user wants to see the interface." +msgstr "用户希望看到的界面语言。" + +#: build/lib/core/models.py:258 core/models.py:258 +msgid "The timezone in which the user wants to see times." +msgstr "用户查看时间希望的时区。" + +#: build/lib/core/models.py:261 core/models.py:261 +msgid "device" +msgstr "设备" + +#: build/lib/core/models.py:263 core/models.py:263 +msgid "Whether the user is a device or a real user." +msgstr "用户是设备还是真实用户。" + +#: build/lib/core/models.py:266 core/models.py:266 +msgid "staff status" +msgstr "员工状态" + +#: build/lib/core/models.py:268 core/models.py:268 +msgid "Whether the user can log into this admin site." +msgstr "用户是否可以登录该管理员站点。" + +#: build/lib/core/models.py:271 core/models.py:271 +msgid "active" +msgstr "激活" + +#: build/lib/core/models.py:274 core/models.py:274 +msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。" + +#: build/lib/core/models.py:286 core/models.py:286 +msgid "user" +msgstr "用户" + +#: build/lib/core/models.py:287 core/models.py:287 +msgid "users" +msgstr "个用户" + +#: build/lib/core/models.py:470 build/lib/core/models.py:1155 +#: core/models.py:470 core/models.py:1155 +msgid "title" +msgstr "标题" + +#: build/lib/core/models.py:471 core/models.py:471 +msgid "excerpt" +msgstr "摘要" + +#: build/lib/core/models.py:519 core/models.py:519 +msgid "Document" +msgstr "文档" + +#: build/lib/core/models.py:520 core/models.py:520 +msgid "Documents" +msgstr "个文档" + +#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532 +#: core/models.py:873 +msgid "Untitled Document" +msgstr "未命名文档" + +#: build/lib/core/models.py:908 core/models.py:908 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "{name} 与您共享了一个文档!" + +#: build/lib/core/models.py:912 core/models.py:912 +#, python-brace-format +msgid "{name} invited you with the role \"{role}\" on the following document:" +msgstr "{name} 邀请您以“{role}”角色访问以下文档:" + +#: build/lib/core/models.py:918 core/models.py:918 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} 与您共享了一个文档:{title}" + +#: build/lib/core/models.py:1016 core/models.py:1016 +msgid "Document/user link trace" +msgstr "文档/用户链接跟踪" + +#: build/lib/core/models.py:1017 core/models.py:1017 +msgid "Document/user link traces" +msgstr "个文档/用户链接跟踪" + +#: build/lib/core/models.py:1023 core/models.py:1023 +msgid "A link trace already exists for this document/user." +msgstr "此文档/用户的链接跟踪已存在。" + +#: build/lib/core/models.py:1046 core/models.py:1046 +msgid "Document favorite" +msgstr "文档收藏" + +#: build/lib/core/models.py:1047 core/models.py:1047 +msgid "Document favorites" +msgstr "文档收藏夹" + +#: build/lib/core/models.py:1053 core/models.py:1053 +msgid "This document is already targeted by a favorite relation instance for the same user." +msgstr "该文档已被同一用户的收藏关系实例关联。" + +#: build/lib/core/models.py:1075 core/models.py:1075 +msgid "Document/user relation" +msgstr "文档/用户关系" + +#: build/lib/core/models.py:1076 core/models.py:1076 +msgid "Document/user relations" +msgstr "文档/用户关系集" + +#: build/lib/core/models.py:1082 core/models.py:1082 +msgid "This user is already in this document." +msgstr "该用户已在此文档中。" + +#: build/lib/core/models.py:1088 core/models.py:1088 +msgid "This team is already in this document." +msgstr "该团队已在此文档中。" + +#: build/lib/core/models.py:1094 build/lib/core/models.py:1242 +#: core/models.py:1094 core/models.py:1242 +msgid "Either user or team must be set, not both." +msgstr "必须设置用户或团队之一,不能同时设置两者。" + +#: build/lib/core/models.py:1156 core/models.py:1156 +msgid "description" +msgstr "说明" + +#: build/lib/core/models.py:1157 core/models.py:1157 +msgid "code" +msgstr "代码" + +#: build/lib/core/models.py:1158 core/models.py:1158 +msgid "css" +msgstr "css" + +#: build/lib/core/models.py:1160 core/models.py:1160 +msgid "public" +msgstr "公开" + +#: build/lib/core/models.py:1162 core/models.py:1162 +msgid "Whether this template is public for anyone to use." +msgstr "该模板是否公开供任何人使用。" + +#: build/lib/core/models.py:1168 core/models.py:1168 +msgid "Template" +msgstr "模板" + +#: build/lib/core/models.py:1169 core/models.py:1169 +msgid "Templates" +msgstr "模板" + +#: build/lib/core/models.py:1223 core/models.py:1223 +msgid "Template/user relation" +msgstr "模板/用户关系" + +#: build/lib/core/models.py:1224 core/models.py:1224 +msgid "Template/user relations" +msgstr "模板/用户关系集" + +#: build/lib/core/models.py:1230 core/models.py:1230 +msgid "This user is already in this template." +msgstr "该用户已在此模板中。" + +#: build/lib/core/models.py:1236 core/models.py:1236 +msgid "This team is already in this template." +msgstr "该团队已在此模板中。" + +#: build/lib/core/models.py:1259 core/models.py:1259 +msgid "email address" +msgstr "电子邮件地址" + +#: build/lib/core/models.py:1278 core/models.py:1278 +msgid "Document invitation" +msgstr "文档邀请" + +#: build/lib/core/models.py:1279 core/models.py:1279 +msgid "Document invitations" +msgstr "文档邀请" + +#: build/lib/core/models.py:1299 core/models.py:1299 +msgid "This email is already associated to a registered user." +msgstr "此电子邮件已经与现有注册用户关联。" + +#: core/templates/mail/html/invitation.html:162 +#: core/templates/mail/text/invitation.txt:3 +msgid "Logo email" +msgstr "徽标邮件" + +#: core/templates/mail/html/invitation.html:209 +#: core/templates/mail/text/invitation.txt:10 +msgid "Open" +msgstr "打开" + +#: core/templates/mail/html/invitation.html:226 +#: core/templates/mail/text/invitation.txt:14 +msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " +msgstr " Docs——您的全新必备工具,帮助团队组织、共享和协作处理文档。 " + +#: core/templates/mail/html/invitation.html:233 +#: core/templates/mail/text/invitation.txt:16 +#, python-format +msgid " Brought to you by %(brandname)s " +msgstr " 由 %(brandname)s 倾力打造。 " + diff --git a/submissions/devoteam/docs/src/backend/manage.py b/submissions/devoteam/docs/src/backend/manage.py new file mode 100644 index 00000000..ab643f93 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +""" +impress's sandbox management script. +""" + +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings") + os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + + from configurations.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/submissions/devoteam/docs/src/backend/pyproject.toml b/submissions/devoteam/docs/src/backend/pyproject.toml new file mode 100644 index 00000000..509e75a3 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/pyproject.toml @@ -0,0 +1,148 @@ +# +# impress package +# +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "impress" +version = "3.3.0" +authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: Django", + "Framework :: Django :: 5", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", +] +description = "An application to print markdown to pdf from a set of managed templates." +keywords = ["Django", "Contacts", "Templates", "RBAC"] +license = { file = "LICENSE" } +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "beautifulsoup4==4.13.4", + "boto3==1.38.27", + "Brotli==1.1.0", + "celery[redis]==5.5.3", + "django-configurations==2.5.1", + "django-cors-headers==4.7.0", + "django-countries==7.6.1", + "django-filter==25.1", + "django-lasuite[all]==0.0.9", + "django-parler==2.3", + "django-redis==5.4.0", + "django-storages[s3]==1.14.6", + "django-timezone-field>=5.1", + "django==5.1.9", + "django-treebeard==4.7.1", + "djangorestframework==3.16.0", + "drf_spectacular==0.28.0", + "dockerflow==2024.4.2", + "easy_thumbnails==2.10", + "factory_boy==3.3.3", + "gunicorn==23.0.0", + "jsonschema==4.24.0", + "lxml==5.4.0", + "markdown==3.8", + "mozilla-django-oidc==4.0.1", + "nested-multipart-parser==1.5.0", + "openai==1.82.1", + "psycopg[binary]==3.2.9", + "pycrdt==0.12.20", + "PyJWT==2.10.1", + "python-magic==0.4.27", + "redis<6.0.0", + "requests==2.32.3", + "sentry-sdk==2.29.1", + "whitenoise==6.9.0", +] + +[project.urls] +"Bug Tracker" = "https://github.com/numerique-gouv/impress/issues/new" +"Changelog" = "https://github.com/numerique-gouv/impress/blob/main/CHANGELOG.md" +"Homepage" = "https://github.com/numerique-gouv/impress" +"Repository" = "https://github.com/numerique-gouv/impress" + +[project.optional-dependencies] +dev = [ + "django-extensions==4.1", + "django-test-migrations==1.5.0", + "drf-spectacular-sidecar==2025.6.1", + "freezegun==1.5.2", + "ipdb==0.13.13", + "ipython==9.3.0", + "pyfakefs==5.8.0", + "pylint-django==2.6.1", + "pylint==3.3.7", + "pytest-cov==6.1.1", + "pytest-django==4.11.1", + "pytest==8.3.5", + "pytest-icdiff==0.9", + "pytest-xdist==3.7.0", + "responses==0.25.7", + "ruff==0.11.12", + "types-requests==2.32.0.20250602", +] + +[tool.setuptools] +packages = { find = { where = ["."], exclude = ["tests"] } } +zip-safe = true + +[tool.distutils.bdist_wheel] +universal = true + +[tool.ruff] +exclude = [ + ".git", + ".venv", + "build", + "venv", + "__pycache__", +] +line-length = 88 + + +[tool.ruff.lint] +ignore = ["DJ001", "PLR2004"] +select = [ + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "DJ", # flake8-django + "I", # isort + "PLC", # pylint-convention + "PLE", # pylint-error + "PLR", # pylint-refactoring + "PLW", # pylint-warning + "RUF100", # Ruff unused-noqa + "RUF200", # Ruff check pyproject.toml + "S", # flake8-bandit + "SLF", # flake8-self + "T20", # flake8-print +] + +[tool.ruff.lint.isort] +section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"] +sections = { impress=["core"], django=["django"] } +extra-standard-library = ["tomllib"] + +[tool.ruff.lint.per-file-ignores] +"**/tests/*" = ["S", "SLF"] + +[tool.pytest.ini_options] +addopts = [ + "-v", + "--cov-report", + "term-missing", + # Allow test files to have the same name in different directories. + "--import-mode=importlib", +] +python_files = [ + "test_*.py", + "tests.py", +] diff --git a/submissions/devoteam/docs/src/backend/setup.py b/submissions/devoteam/docs/src/backend/setup.py new file mode 100644 index 00000000..8dcbc647 --- /dev/null +++ b/submissions/devoteam/docs/src/backend/setup.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +"""Setup file for the impress module. All configuration stands in the setup.cfg file.""" +# coding: utf-8 + +from setuptools import setup + +setup() diff --git a/submissions/devoteam/docs/src/frontend/.prettierrc.js b/submissions/devoteam/docs/src/frontend/.prettierrc.js new file mode 100644 index 00000000..a4db989c --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + semi: true, + trailingComma: 'all', + singleQuote: true, + printWidth: 80, + tabWidth: 2, +}; diff --git a/submissions/devoteam/docs/src/frontend/Dockerfile b/submissions/devoteam/docs/src/frontend/Dockerfile new file mode 100644 index 00000000..d59517af --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/Dockerfile @@ -0,0 +1,69 @@ +FROM node:24-alpine AS frontend-deps + +# Upgrade system packages to install security updates +RUN apk update && \ + apk upgrade && \ + rm -rf /var/cache/apk/* + +WORKDIR /home/frontend/ + +COPY ./src/frontend/package.json ./package.json +COPY ./src/frontend/yarn.lock ./yarn.lock +COPY ./src/frontend/apps/impress/package.json ./apps/impress/package.json +COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json + +RUN yarn install --frozen-lockfile + +COPY .dockerignore ./.dockerignore +COPY ./src/frontend/.prettierrc.js ./.prettierrc.js +COPY ./src/frontend/packages/eslint-config-impress ./packages/eslint-config-impress +COPY ./src/frontend/apps/impress ./apps/impress + +### ---- Front-end builder image ---- +FROM frontend-deps AS impress + +WORKDIR /home/frontend/apps/impress + +FROM frontend-deps AS impress-dev + +WORKDIR /home/frontend/apps/impress + +EXPOSE 3000 + +CMD [ "yarn", "dev"] + +# Tilt will rebuild impress target so, we dissociate impress and impress-builder +# to avoid rebuilding the app at every changes. +FROM impress AS impress-builder + +WORKDIR /home/frontend/apps/impress + +ARG API_ORIGIN +ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN} + +ARG SW_DEACTIVATED +ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED} + +ARG PUBLISH_AS_MIT +ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT} + +RUN yarn prettier --write . && yarn build + +# ---- Front-end image ---- +FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production + +# Un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +COPY --from=impress-builder \ + /home/frontend/apps/impress/out \ + /usr/share/nginx/html + +COPY ./src/frontend/apps/impress/conf/default.conf /etc/nginx/conf.d +COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint +# Dans le Dockerfile, après la ligne COPY du entrypoint +RUN chmod +x /usr/local/bin/entrypoint +ENTRYPOINT [ "/usr/local/bin/entrypoint" ] + +CMD ["nginx", "-g", "daemon off;"] diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/.eslintrc.js b/submissions/devoteam/docs/src/frontend/apps/e2e/.eslintrc.js new file mode 100644 index 00000000..46f10c31 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + root: true, + extends: ['impress/playwright'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + ignorePatterns: ['node_modules'], +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/.gitignore b/submissions/devoteam/docs/src/frontend/apps/e2e/.gitignore new file mode 100644 index 00000000..d248afc8 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/.gitignore @@ -0,0 +1,7 @@ +# e2e +test-results/ +report/ +blob-report/ +playwright/.auth/ +playwright/.cache/ +screenshots/ diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/404.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/404.spec.ts new file mode 100644 index 00000000..e55acfe5 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/404.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect( + page.locator('header').first().locator('h2').getByText('Docs'), + ).toBeVisible(); + await page.goto('unknown-page404'); +}); + +test.describe('404', () => { + test('Checks all the elements are visible', async ({ page }) => { + await expect( + page.getByText( + 'It seems that the page you are looking for does not exist or cannot be displayed correctly.', + ), + ).toBeVisible(); + await expect(page.getByText('Home')).toBeVisible(); + }); + + test('checks go back to home page redirects to home page', async ({ + page, + }) => { + await page.getByText('Home').click(); + await expect(page).toHaveURL('/'); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/logo-suite-numerique.png b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/logo-suite-numerique.png new file mode 100644 index 00000000..243c9662 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/logo-suite-numerique.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/test.html b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/test.html new file mode 100644 index 00000000..a1247b95 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/test.html @@ -0,0 +1,22 @@ + + + Test unsafe file + + +

Hello svg

+ test + + + + Hello svg + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/test.svg b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/test.svg new file mode 100644 index 00000000..6980c934 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/assets/test.svg @@ -0,0 +1,13 @@ + + + + Hello svg + diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/auth.setup.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/auth.setup.ts new file mode 100644 index 00000000..a6e18985 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/auth.setup.ts @@ -0,0 +1,59 @@ +import { FullConfig, FullProject, chromium, expect } from '@playwright/test'; + +import { keyCloakSignIn } from './common'; + +const saveStorageState = async ( + browserConfig: FullProject, +) => { + const browserName = browserConfig?.name || 'chromium'; + + const { storageState, ...useConfig } = browserConfig?.use; + const browser = await chromium.launch(); + const context = await browser.newContext(useConfig); + const page = await context.newPage(); + + try { + await page.goto('/', { waitUntil: 'networkidle' }); + await page.content(); + await expect(page.getByText('Docs').first()).toBeVisible(); + + await keyCloakSignIn(page, browserName); + + await expect( + page.locator('header').first().getByRole('button', { + name: 'Logout', + }), + ).toBeVisible(); + + await page.context().storageState({ + path: storageState as string, + }); + } catch (error) { + console.log(error); + + await page.screenshot({ + path: `./screenshots/${browserName}-${Date.now()}.png`, + }); + // Get console logs + const consoleLogs = await page.evaluate(() => + console.log(window.console.log), + ); + console.log(consoleLogs); + } finally { + await browser.close(); + } +}; + +async function globalSetup(config: FullConfig) { + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + const chromeConfig = config.projects.find((p) => p.name === 'chromium')!; + const firefoxConfig = config.projects.find((p) => p.name === 'firefox')!; + const webkitConfig = config.projects.find((p) => p.name === 'webkit')!; + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + + await saveStorageState(chromeConfig); + await saveStorageState(webkitConfig); + await saveStorageState(firefoxConfig); +} + +export default globalSetup; diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/common.ts new file mode 100644 index 00000000..9c5d5b02 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -0,0 +1,331 @@ +import { Page, expect } from '@playwright/test'; + +export const CONFIG = { + AI_FEATURE_ENABLED: true, + CRISP_WEBSITE_ID: null, + COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', + COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: false, + ENVIRONMENT: 'development', + FRONTEND_CSS_URL: null, + FRONTEND_HOMEPAGE_FEATURE_ENABLED: true, + FRONTEND_THEME: null, + MEDIA_BASE_URL: 'http://localhost:8083', + LANGUAGES: [ + ['en-us', 'English'], + ['fr-fr', 'Français'], + ['de-de', 'Deutsch'], + ['nl-nl', 'Nederlands'], + ['es-es', 'Español'], + ], + LANGUAGE_CODE: 'en-us', + POSTHOG_KEY: {}, + SENTRY_DSN: null, + theme_customization: {}, +} as const; + +export const overrideConfig = async ( + page: Page, + newConfig: { [K in keyof typeof CONFIG]?: unknown }, +) => + await page.route('**/api/v1.0/config/', async (route) => { + const request = route.request(); + if (request.method().includes('GET')) { + await route.fulfill({ + json: { + ...CONFIG, + ...newConfig, + }, + }); + } else { + await route.continue(); + } + }); + +export const keyCloakSignIn = async ( + page: Page, + browserName: string, + fromHome: boolean = true, +) => { + if (fromHome) { + await page.getByRole('button', { name: 'Start Writing' }).first().click(); + } + + const login = `user-e2e-${browserName}`; + const password = `password-e2e-${browserName}`; + + await expect( + page.locator('.login-pf-page-header').getByText('impress'), + ).toBeVisible(); + + if (await page.getByLabel('Restart login').isVisible()) { + await page.getByLabel('Restart login').click(); + } + + await page.getByRole('textbox', { name: 'username' }).fill(login); + await page.getByRole('textbox', { name: 'password' }).fill(password); + await page.click('input[type="submit"]', { force: true }); +}; + +export const randomName = (name: string, browserName: string, length: number) => + Array.from({ length }, (_el, index) => { + return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`; + }); + +export const createDoc = async ( + page: Page, + docName: string, + browserName: string, + length: number = 1, +) => { + const randomDocs = randomName(docName, browserName, length); + + for (let i = 0; i < randomDocs.length; i++) { + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + + await page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + await page.waitForURL('**/docs/**', { + timeout: 10000, + waitUntil: 'networkidle', + }); + + const input = page.getByLabel('doc title input'); + await expect(input).toBeVisible(); + await expect(input).toHaveText(''); + await input.click(); + + await input.fill(randomDocs[i]); + await input.blur(); + } + + return randomDocs; +}; + +export const verifyDocName = async (page: Page, docName: string) => { + await expect( + page.getByLabel('It is the card information about the document.'), + ).toBeVisible({ + timeout: 10000, + }); + + try { + await expect( + page.getByRole('textbox', { name: 'doc title input' }), + ).toHaveText(docName); + } catch { + await expect(page.getByRole('heading', { name: docName })).toBeVisible(); + } +}; + +export const addNewMember = async ( + page: Page, + index: number, + role: 'Administrator' | 'Owner' | 'Editor' | 'Reader', + fillText: string = 'user ', +) => { + const responsePromiseSearchUser = page.waitForResponse( + (response) => + response.url().includes(`/users/?q=${encodeURIComponent(fillText)}`) && + response.status() === 200, + ); + + const inputSearch = page.getByRole('combobox', { + name: 'Quick search input', + }); + + // Select a new user + await inputSearch.fill(fillText); + + // Intercept response + const responseSearchUser = await responsePromiseSearchUser; + const users = (await responseSearchUser.json()) as { + email: string; + }[]; + + // Choose user + await page.getByRole('option', { name: users[index].email }).click(); + + // Choose a role + await page.getByLabel('doc-role-dropdown').click(); + await page.getByLabel(role).click(); + await page.getByRole('button', { name: 'Invite' }).click(); + + return users[index].email; +}; + +export const getGridRow = async (page: Page, title: string) => { + const docsGrid = page.getByRole('grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const rows = docsGrid.getByRole('row'); + + const row = rows.filter({ + hasText: title, + }); + + await expect(row).toBeVisible(); + + return row; +}; + +interface GoToGridDocOptions { + nthRow?: number; + title?: string; +} +export const goToGridDoc = async ( + page: Page, + { nthRow = 1, title }: GoToGridDocOptions = {}, +) => { + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const rows = docsGrid.getByRole('row'); + + const row = title + ? rows.filter({ + hasText: title, + }) + : rows.nth(nthRow); + + await expect(row).toBeVisible(); + + const docTitleContent = row.locator('[aria-describedby="doc-title"]').first(); + const docTitle = await docTitleContent.textContent(); + expect(docTitle).toBeDefined(); + + await row.getByRole('link').first().click(); + + return docTitle as string; +}; + +export const mockedDocument = async (page: Page, json: object) => { + await page.route('**/documents/**/', async (route) => { + const request = route.request(); + if ( + request.method().includes('GET') && + !request.url().includes('page=') && + !request.url().includes('versions') && + !request.url().includes('accesses') && + !request.url().includes('invitations') + ) { + await route.fulfill({ + json: { + id: 'mocked-document-id', + content: '', + title: 'Mocked document', + accesses: [], + abilities: { + destroy: false, // Means not owner + link_configuration: false, + versions_destroy: false, + versions_list: true, + versions_retrieve: true, + accesses_manage: false, // Means not admin + update: false, + partial_update: false, // Means not editor + retrieve: true, + }, + link_reach: 'restricted', + created_at: '2021-09-01T09:00:00Z', + ...json, + }, + }); + } else { + await route.continue(); + } + }); +}; + +export const mockedInvitations = async (page: Page, json?: object) => { + await page.route('**/invitations/**/', async (route) => { + const request = route.request(); + if ( + request.method().includes('GET') && + request.url().includes('invitations') && + request.url().includes('page=') + ) { + await route.fulfill({ + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: '120ec765-43af-4602-83eb-7f4e1224548a', + abilities: { + destroy: true, + update: true, + partial_update: true, + retrieve: true, + }, + created_at: '2024-10-03T12:19:26.107687Z', + email: 'test@invitation.test', + document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6', + role: 'editor', + issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d', + is_expired: false, + ...json, + }, + ], + }, + }); + } else { + await route.continue(); + } + }); +}; + +export const mockedAccesses = async (page: Page, json?: object) => { + await page.route('**/accesses/**/', async (route) => { + const request = route.request(); + if ( + request.method().includes('GET') && + request.url().includes('accesses') && + request.url().includes('page=') + ) { + await route.fulfill({ + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87', + user: { + id: 'b4a21bb3-722e-426c-9f78-9d190eda641c', + email: 'test@accesses.test', + }, + team: '', + role: 'reader', + abilities: { + destroy: true, + update: true, + partial_update: true, + retrieve: true, + set_role_to: ['administrator', 'editor'], + }, + ...json, + }, + ], + }, + }); + } else { + await route.continue(); + } + }); +}; + +export const expectLoginPage = async (page: Page) => + await expect( + page.getByRole('heading', { name: 'Collaborative writing' }), + ).toBeVisible(); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts new file mode 100644 index 00000000..21bb085e --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -0,0 +1,159 @@ +import path from 'path'; + +import { expect, test } from '@playwright/test'; + +import { CONFIG, createDoc, overrideConfig } from './common'; + +test.describe('Config', () => { + test('it checks that sentry is trying to init from config endpoint', async ({ + page, + }) => { + await overrideConfig(page, { + SENTRY_DSN: 'https://sentry.io/123', + }); + + const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123'; + const consoleMessage = page.waitForEvent('console', { + timeout: 5000, + predicate: (msg) => msg.text().includes(invalidMsg), + }); + + await page.goto('/'); + + expect((await consoleMessage).text()).toContain(invalidMsg); + }); + + test('it checks that media server is configured from config endpoint', async ({ + page, + browserName, + }) => { + await page.goto('/'); + + await createDoc(page, 'doc-media', browserName, 1); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.locator('.bn-block-outer').last().fill('Anything'); + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); + + const image = page.getByRole('img', { name: 'logo-suite-numerique.png' }); + + await expect(image).toBeVisible(); + + // Check src of image + expect(await image.getAttribute('src')).toMatch( + /http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/, + ); + }); + + test('it checks that collaboration server is configured from config endpoint', async ({ + page, + }) => { + await page.goto('/'); + + void page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + const webSocket = await page.waitForEvent('websocket', (webSocket) => { + return webSocket.url().includes('ws://localhost:4444/collaboration/ws/'); + }); + expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/'); + }); + + test('it checks the AI feature flag from config endpoint', async ({ + page, + browserName, + }) => { + await overrideConfig(page, { + AI_FEATURE_ENABLED: false, + }); + + await page.goto('/'); + + await createDoc(page, 'doc-ai-feature', browserName, 1); + + await page.locator('.bn-block-outer').last().fill('Anything'); + await page.getByText('Anything').selectText(); + expect( + await page.locator('button[data-test="convertMarkdown"]').count(), + ).toBe(1); + expect(await page.locator('button[data-test="ai-actions"]').count()).toBe( + 0, + ); + }); + + test('it checks that Crisp is trying to init from config endpoint', async ({ + page, + }) => { + await overrideConfig(page, { + CRISP_WEBSITE_ID: '1234', + }); + + await page.goto('/'); + + await expect( + page.locator('#crisp-chatbox').getByText('Invalid website'), + ).toBeVisible(); + }); + + test('it checks FRONTEND_CSS_URL config', async ({ page }) => { + await overrideConfig(page, { + FRONTEND_CSS_URL: 'http://localhost:123465/css/style.css', + }); + + await page.goto('/'); + + await expect( + page + .locator('head link[href="http://localhost:123465/css/style.css"]') + .first(), + ).toBeAttached(); + }); +}); + +test.describe('Config: Not loggued', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('it checks the config api is called', async ({ page }) => { + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/config/') && response.status() === 200, + ); + + await page.goto('/'); + + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + + const json = (await response.json()) as typeof CONFIG; + const { theme_customization, ...configApi } = json; + expect(theme_customization).toBeDefined(); + const { theme_customization: _, ...CONFIG_LEFT } = CONFIG; + + expect(configApi).toStrictEqual(CONFIG_LEFT); + }); + + test('it checks that theme is configured from config endpoint', async ({ + page, + }) => { + await overrideConfig(page, { + FRONTEND_THEME: 'dsfr', + }); + + await page.goto('/'); + + const header = page.locator('header').first(); + // alt 'Gouvernement Logo' comes from the theme + await expect(header.getByAltText('Gouvernement Logo')).toBeVisible(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts new file mode 100644 index 00000000..52aedce0 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; + +import { + createDoc, + goToGridDoc, + keyCloakSignIn, + randomName, + verifyDocName, +} from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Create', () => { + test('it creates a doc', async ({ page, browserName }) => { + const [docTitle] = await createDoc(page, 'My new doc', browserName, 1); + + await page.waitForFunction( + () => document.title.match(/My new doc - Docs/), + { timeout: 5000 }, + ); + + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + await expect(docsGrid.getByText(docTitle)).toBeVisible(); + }); +}); + +test.describe('Doc Create: Not loggued', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('it creates a doc server way', async ({ + page, + browserName, + request, + }) => { + const markdown = `This is a normal text\n\n# And this is a large heading`; + const [title] = randomName('My server way doc create', browserName, 1); + const data = { + title, + content: markdown, + sub: `user@${browserName}.e2e`, + email: `user@${browserName}.e2e`, + }; + + const newDoc = await request.post( + `http://localhost:8071/api/v1.0/documents/create-for-owner/`, + { + data, + headers: { + Authorization: 'Bearer test-e2e', + format: 'json', + }, + }, + ); + + expect(newDoc.ok()).toBeTruthy(); + + await keyCloakSignIn(page, browserName); + + await goToGridDoc(page, { title }); + + await verifyDocName(page, title); + + const editor = page.locator('.ProseMirror'); + await expect(editor.getByText('This is a normal text')).toBeVisible(); + await expect( + editor.locator('h1').getByText('And this is a large heading'), + ).toBeVisible(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts new file mode 100644 index 00000000..722769af --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -0,0 +1,595 @@ +import path from 'path'; + +import { expect, test } from '@playwright/test'; +import cs from 'convert-stream'; + +import { + CONFIG, + addNewMember, + createDoc, + goToGridDoc, + mockedDocument, + verifyDocName, +} from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Editor', () => { + test('it checks default toolbar buttons are displayed', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-toolbar', browserName, 1); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('test content'); + + await editor + .getByText('test content', { + exact: true, + }) + .selectText(); + + const toolbar = page.locator('.bn-formatting-toolbar'); + await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible(); + await expect(toolbar.locator('button[data-test="italic"]')).toBeVisible(); + await expect( + toolbar.locator('button[data-test="underline"]'), + ).toBeVisible(); + await expect(toolbar.locator('button[data-test="strike"]')).toBeVisible(); + await expect( + toolbar.locator('button[data-test="alignTextLeft"]'), + ).toBeVisible(); + await expect( + toolbar.locator('button[data-test="alignTextCenter"]'), + ).toBeVisible(); + await expect( + toolbar.locator('button[data-test="alignTextRight"]'), + ).toBeVisible(); + await expect(toolbar.locator('button[data-test="colors"]')).toBeVisible(); + await expect( + toolbar.locator('button[data-test="unnestBlock"]'), + ).toBeVisible(); + await expect( + toolbar.locator('button[data-test="createLink"]'), + ).toBeVisible(); + }); + + /** + * We check: + * - connection to the collaborative server + * - signal of the backend to the collaborative server (connection should close) + * - reconnection to the collaborative server + */ + test('checks the connection with collaborative server', async ({ page }) => { + let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes('ws://localhost:4444/collaboration/ws/?room='); + }); + + await page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + let webSocket = await webSocketPromise; + expect(webSocket.url()).toContain( + 'ws://localhost:4444/collaboration/ws/?room=', + ); + + // Is connected + let framesentPromise = webSocket.waitForEvent('framesent'); + + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); + + let framesent = await framesentPromise; + expect(framesent.payload).not.toBeNull(); + + await page.getByRole('button', { name: 'Share' }).click(); + + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + + // When the visibility is changed, the ws should closed the connection (backend signal) + const wsClosePromise = webSocket.waitForEvent('close'); + + await selectVisibility.click(); + await page.getByLabel('Connected').click(); + + // Assert that the doc reconnects to the ws + const wsClose = await wsClosePromise; + expect(wsClose.isClosed()).toBeTruthy(); + + // Check the ws is connected again + webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes('ws://localhost:4444/collaboration/ws/?room='); + }); + + webSocket = await webSocketPromise; + framesentPromise = webSocket.waitForEvent('framesent'); + framesent = await framesentPromise; + expect(framesent.payload).not.toBeNull(); + }); + + test('markdown button converts from markdown to the editor syntax json', async ({ + page, + browserName, + }) => { + const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1); + + await verifyDocName(page, randomDoc[0]); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('[test markdown](http://test-markdown.html)'); + + await expect(editor.getByText('[test markdown]')).toBeVisible(); + + await editor.getByText('[test markdown]').selectText(); + await page.locator('button[data-test="convertMarkdown"]').click(); + + await expect(editor.getByText('[test markdown]')).toBeHidden(); + await expect( + editor.getByRole('link', { + name: 'test markdown', + }), + ).toHaveAttribute('href', 'http://test-markdown.html'); + }); + + test('it renders correctly when we switch from one doc to another', async ({ + page, + browserName, + }) => { + // Check the first doc + const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1); + await verifyDocName(page, firstDoc); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('Hello World Doc 1'); + await expect(editor.getByText('Hello World Doc 1')).toBeVisible(); + + // Check the second doc + const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1); + await verifyDocName(page, secondDoc); + + await expect(editor.getByText('Hello World Doc 1')).toBeHidden(); + await editor.click(); + await editor.fill('Hello World Doc 2'); + await expect(editor.getByText('Hello World Doc 2')).toBeVisible(); + + // Check the first doc again + await goToGridDoc(page, { + title: firstDoc, + }); + await verifyDocName(page, firstDoc); + await expect(editor.getByText('Hello World Doc 2')).toBeHidden(); + await expect(editor.getByText('Hello World Doc 1')).toBeVisible(); + + await page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + await expect(editor.getByText('Hello World Doc 1')).toBeHidden(); + await expect(editor.getByText('Hello World Doc 2')).toBeHidden(); + }); + + test('it saves the doc when we change pages', async ({ + page, + browserName, + }) => { + // Check the first doc + const [doc] = await createDoc(page, 'doc-saves-change', browserName); + await verifyDocName(page, doc); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('Hello World Doc persisted 1'); + await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible(); + + const [secondDoc] = await createDoc( + page, + 'doc-saves-change-other', + browserName, + ); + + await verifyDocName(page, secondDoc); + + await goToGridDoc(page, { + title: doc, + }); + + await verifyDocName(page, doc); + await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible(); + }); + + test('it saves the doc when we quit pages', async ({ page, browserName }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(browserName === 'webkit', 'This test is very flaky with webkit'); + + // Check the first doc + const [doc] = await createDoc(page, 'doc-quit-1', browserName, 1); + await verifyDocName(page, doc); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('Hello World Doc persisted 2'); + await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible(); + + const urlDoc = page.url(); + await page.goto(urlDoc); + + await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible(); + }); + + test('it cannot edit if viewer', async ({ page }) => { + await mockedDocument(page, { + abilities: { + destroy: false, // Means not owner + link_configuration: false, + versions_destroy: false, + versions_list: true, + versions_retrieve: true, + accesses_manage: false, // Means not admin + update: false, + partial_update: false, // Means not editor + retrieve: true, + }, + }); + + await goToGridDoc(page); + + const card = page.getByLabel('It is the card information'); + await expect(card).toBeVisible(); + + await expect(card.getByText('Reader')).toBeVisible(); + }); + + test('it adds an image to the doc editor', async ({ page, browserName }) => { + await createDoc(page, 'doc-image', browserName, 1); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.locator('.bn-block-outer').last().fill('Hello World'); + + await page.keyboard.press('Enter'); + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); + + const image = page.getByRole('img', { name: 'logo-suite-numerique.png' }); + + await expect(image).toBeVisible(); + + // Check src of image + expect(await image.getAttribute('src')).toMatch( + /http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/, + ); + }); + + test('it checks the AI buttons', async ({ page, browserName }) => { + await page.route(/.*\/ai-translate\//, async (route) => { + const request = route.request(); + if (request.method().includes('POST')) { + await route.fulfill({ + json: { + answer: 'Bonjour le monde', + }, + }); + } else { + await route.continue(); + } + }); + + await createDoc(page, 'doc-ai', browserName, 1); + + await page.locator('.bn-block-outer').last().fill('Hello World'); + + const editor = page.locator('.ProseMirror'); + await editor.getByText('Hello').selectText(); + + await page.getByRole('button', { name: 'AI' }).click(); + + await expect( + page.getByRole('menuitem', { name: 'Use as prompt' }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Rephrase' }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Summarize' }), + ).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Language' }), + ).toBeVisible(); + + await page.getByRole('menuitem', { name: 'Language' }).hover(); + await expect( + page.getByRole('menuitem', { name: 'English', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'French', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'German', exact: true }), + ).toBeVisible(); + + await page.getByRole('menuitem', { name: 'English', exact: true }).click(); + + await expect(editor.getByText('Bonjour le monde')).toBeVisible(); + }); + + [ + { ai_transform: false, ai_translate: false }, + { ai_transform: true, ai_translate: false }, + { ai_transform: false, ai_translate: true }, + ].forEach(({ ai_transform, ai_translate }) => { + test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({ + page, + browserName, + }) => { + await mockedDocument(page, { + accesses: [ + { + id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', + role: 'owner', + user: { + email: 'super@owner.com', + full_name: 'Super Owner', + }, + }, + ], + abilities: { + destroy: true, // Means owner + link_configuration: true, + ai_transform, + ai_translate, + accesses_manage: true, + accesses_view: true, + update: true, + partial_update: true, + retrieve: true, + }, + link_reach: 'restricted', + link_role: 'editor', + created_at: '2021-09-01T09:00:00Z', + title: '', + }); + + const [randomDoc] = await createDoc( + page, + 'doc-editor-ai', + browserName, + 1, + ); + + await verifyDocName(page, randomDoc); + + await page.locator('.bn-block-outer').last().fill('Hello World'); + + const editor = page.locator('.ProseMirror'); + await editor.getByText('Hello').selectText(); + + /* eslint-disable playwright/no-conditional-expect */ + /* eslint-disable playwright/no-conditional-in-test */ + if (!ai_transform && !ai_translate) { + await expect(page.getByRole('button', { name: 'AI' })).toBeHidden(); + return; + } + + await page.getByRole('button', { name: 'AI' }).click(); + + if (ai_transform) { + await expect( + page.getByRole('menuitem', { name: 'Use as prompt' }), + ).toBeVisible(); + } else { + await expect( + page.getByRole('menuitem', { name: 'Use as prompt' }), + ).toBeHidden(); + } + + if (ai_translate) { + await expect( + page.getByRole('menuitem', { name: 'Language' }), + ).toBeVisible(); + } else { + await expect( + page.getByRole('menuitem', { name: 'Language' }), + ).toBeHidden(); + } + /* eslint-enable playwright/no-conditional-expect */ + /* eslint-enable playwright/no-conditional-in-test */ + }); + }); + + test('it downloads unsafe files', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); + + const fileChooserPromise = page.waitForEvent('filechooser'); + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`html`); + }); + const responseCheckPromise = page.waitForResponse( + (response) => + response.url().includes('media-check') && response.status() === 200, + ); + + await verifyDocName(page, randomDoc); + + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); + + await page.keyboard.press('Enter'); + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Embedded file').click(); + await page.getByText('Upload file').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.html')); + + await responseCheckPromise; + + await page.locator('.bn-block-content[data-name="test.html"]').click(); + await page.getByRole('button', { name: 'Download file' }).click(); + + await expect( + page.getByText('This file is flagged as unsafe.'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Download' }).click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toContain(`-unsafe.html`); + + const svgBuffer = await cs.toBuffer(await download.createReadStream()); + expect(svgBuffer.toString()).toContain('Hello svg'); + }); + + test('it analyzes uploads', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); + + let requestCount = 0; + await page.route( + /.*\/documents\/.*\/media-check\/\?key=.*/, + async (route) => { + const request = route.request(); + if (request.method().includes('GET')) { + await route.fulfill({ + json: { + status: requestCount ? 'ready' : 'processing', + file: '/anything.html', + }, + }); + + requestCount++; + } else { + await route.continue(); + } + }, + ); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await verifyDocName(page, randomDoc); + + const editor = page.locator('.ProseMirror.bn-editor'); + + await editor.click(); + await editor.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Embedded file').click(); + await page.getByText('Upload file').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.html')); + + await expect(editor.getByText('Analyzing file...')).toBeVisible(); + // The retry takes a few seconds + await expect(editor.getByText('test.html')).toBeVisible({ + timeout: 7000, + }); + await expect(editor.getByText('Analyzing file...')).toBeHidden(); + }); + + test('it checks block editing when not connected to collab server', async ({ + page, + }) => { + await page.route('**/api/v1.0/config/', async (route) => { + const request = route.request(); + if (request.method().includes('GET')) { + await route.fulfill({ + json: { + ...CONFIG, + COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', + COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto('/'); + + void page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + const card = page.getByLabel('It is the card information'); + await expect( + card.getByText('Your network do not allow you to edit'), + ).toBeHidden(); + const editor = page.locator('.ProseMirror'); + + await expect(editor).toHaveAttribute('contenteditable', 'true'); + + await page.getByRole('button', { name: 'Share' }).click(); + + await addNewMember(page, 0, 'Editor', 'impress'); + + // Close the modal + await page.getByRole('button', { name: 'close' }).first().click(); + + await expect( + card.getByText('Your network do not allow you to edit'), + ).toBeVisible({ + timeout: 10000, + }); + + await expect(editor).toHaveAttribute('contenteditable', 'false'); + }); + + test('it checks if callout custom block', async ({ page, browserName }) => { + await createDoc(page, 'doc-toolbar', browserName, 1); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Add a callout block').click(); + + const calloutBlock = page + .locator('div[data-content-type="callout"]') + .first(); + + await expect(calloutBlock).toBeVisible(); + + await calloutBlock.locator('.inline-content').fill('example text'); + + await expect(page.locator('.bn-block').first()).toHaveAttribute( + 'data-background-color', + 'yellow', + ); + + const emojiButton = calloutBlock.getByRole('button'); + await expect(emojiButton).toHaveText('💡'); + await emojiButton.click(); + await page.locator('button[aria-label="⚠️"]').click(); + await expect(emojiButton).toHaveText('⚠️'); + + await page.locator('.bn-side-menu > button').last().click(); + await page.locator('.mantine-Menu-dropdown > button').last().click(); + await page.locator('.bn-color-picker-dropdown > button').last().click(); + + await expect(page.locator('.bn-block').first()).toHaveAttribute( + 'data-background-color', + 'pink', + ); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts new file mode 100644 index 00000000..b41ecc33 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -0,0 +1,314 @@ +import path from 'path'; + +import { expect, test } from '@playwright/test'; +import cs from 'convert-stream'; +import pdf from 'pdf-parse'; + +import { createDoc, verifyDocName } from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Export', () => { + test('it check if all elements are visible', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-editor', browserName, 1); + await page + .getByRole('button', { + name: 'download', + }) + .click(); + + await expect( + page + .locator('div') + .filter({ hasText: /^Download$/ }) + .first(), + ).toBeVisible(); + await expect( + page.getByText('Download your document in a .docx or .pdf format.'), + ).toBeVisible(); + await expect( + page.getByRole('combobox', { name: 'Template' }), + ).toBeVisible(); + await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Close the modal' }), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Download' })).toBeVisible(); + }); + + test('it exports the doc with pdf line break', async ({ + page, + browserName, + }) => { + const [randomDoc] = await createDoc( + page, + 'doc-editor-line-break', + browserName, + 1, + ); + + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.pdf`); + }); + + await verifyDocName(page, randomDoc); + + const editor = page.locator('.ProseMirror.bn-editor'); + + await editor.click(); + await editor.locator('.bn-block-outer').last().fill('Hello'); + + await page.keyboard.press('Enter'); + await editor.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Page Break').click(); + + await expect(editor.locator('.bn-page-break')).toBeVisible(); + + await page.keyboard.press('Enter'); + + await editor.locator('.bn-block-outer').last().fill('World'); + + await page + .getByRole('button', { + name: 'download', + }) + .click(); + + void page + .getByRole('button', { + name: 'Download', + }) + .click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); + + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfData = await pdf(pdfBuffer); + + expect(pdfData.numpages).toBe(2); + expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text + }); + + test('it exports the doc to docx', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); + + const fileChooserPromise = page.waitForEvent('filechooser'); + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.docx`); + }); + + await verifyDocName(page, randomDoc); + + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); + + await page.keyboard.press('Enter'); + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg')); + + const image = page.getByRole('img', { name: 'test.svg' }); + + await expect(image).toBeVisible(); + + await page + .getByRole('button', { + name: 'download', + }) + .click(); + + await page.getByRole('combobox', { name: 'Format' }).click(); + await page.getByRole('option', { name: 'Docx' }).click(); + + void page + .getByRole('button', { + name: 'Download', + }) + .click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`); + }); + + /** + * This test tell us that the export to pdf is working with images + * but it does not tell us if the images are being displayed correctly + * in the pdf. + * + * TODO: Check if the images are displayed correctly in the pdf + */ + test('it exports the docs with images', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); + + const responseCorsPromise = page.waitForResponse( + (response) => + response.url().includes('/cors-proxy/') && response.status() === 200, + ); + + const fileChooserPromise = page.waitForEvent('filechooser'); + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.pdf`); + }); + + await verifyDocName(page, randomDoc); + + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); + + await page.keyboard.press('Enter'); + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg')); + + const image = page.getByRole('img', { name: 'test.svg' }); + + await expect(image).toBeVisible(); + + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Resizable image with caption').click(); + await page.getByRole('tab', { name: 'Embed' }).click(); + await page + .getByRole('textbox', { name: 'Enter URL' }) + .fill('https://docs.numerique.gouv.fr/assets/logo-gouv.png'); + await page.getByText('Embed image').click(); + + await page + .getByRole('button', { + name: 'download', + }) + .click(); + + await page + .getByRole('combobox', { + name: 'Template', + }) + .click(); + + await page + .getByRole('option', { + name: 'Demo Template', + }) + .click({ + delay: 100, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + void page + .getByRole('button', { + name: 'Download', + }) + .click(); + + const responseCors = await responseCorsPromise; + expect(responseCors.ok()).toBe(true); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); + + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfExport = await pdf(pdfBuffer); + const pdfText = pdfExport.text; + + expect(pdfText).toContain('Hello World'); + }); + + test('it exports the doc with quotes', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1); + + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.pdf`); + }); + + const editor = page.locator('.ProseMirror'); + // Trigger slash menu to show menu + await editor.click(); + await editor.fill('/'); + await page.getByText('Quote or excerpt').click(); + + await expect( + editor.locator('.bn-block-content[data-content-type="quote"]'), + ).toBeVisible(); + + await editor.fill('Hello World'); + + await expect(editor.getByText('Hello World')).toHaveCSS( + 'font-style', + 'italic', + ); + + await page + .getByRole('button', { + name: 'download', + }) + .click(); + + void page + .getByRole('button', { + name: 'Download', + }) + .click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); + + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfData = await pdf(pdfBuffer); + + expect(pdfData.text).toContain('Hello World'); // This is the pdf text + }); + + /** + * We cannot assert the line break is visible in the pdf but we can assert the + * line break is visible in the editor and that the pdf is generated. + */ + test('it exports the doc with divider', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'export-divider', browserName, 1); + + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.pdf`); + }); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('Hello World'); + + // Trigger slash menu to show menu + await editor.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Add a horizontal line').click(); + + await expect( + editor.locator('.bn-block-content[data-content-type="divider"]'), + ).toBeVisible(); + + await page + .getByRole('button', { + name: 'download', + }) + .click(); + + void page + .getByRole('button', { + name: 'Download', + }) + .click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); + + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfData = await pdf(pdfBuffer); + expect(pdfData.text).toContain('Hello World'); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts new file mode 100644 index 00000000..12be84ce --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -0,0 +1,333 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, getGridRow } from './common'; + +type SmallDoc = { + id: string; + title: string; +}; + +test.describe('Documents Grid mobile', () => { + test.use({ viewport: { width: 500, height: 1200 } }); + + test('it checks the grid when mobile', async ({ page }) => { + await page.route('**/documents/**', async (route) => { + const request = route.request(); + if (request.method().includes('GET') && request.url().includes('page=')) { + await route.fulfill({ + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: 'b7fd9d9b-0642-4b4f-8617-ce50f69519ed', + title: 'My mocked document', + accesses: [ + { + id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f', + user: { + id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d', + email: 'test@test.test', + full_name: 'John Doe', + short_name: 'John', + }, + team: '', + role: 'owner', + abilities: { + destroy: false, + update: false, + partial_update: false, + retrieve: true, + set_role_to: [], + }, + }, + ], + abilities: { + attachment_upload: true, + destroy: true, + link_configuration: true, + accesses_manage: true, + partial_update: true, + retrieve: true, + update: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + }, + link_role: 'reader', + link_reach: 'public', + created_at: '2024-10-07T13:02:41.085298Z', + updated_at: '2024-10-07T13:30:21.829690Z', + }, + ], + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto('/'); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const rows = docsGrid.getByRole('row'); + const row = rows.filter({ + hasText: 'My mocked document', + }); + + await expect( + row.locator('[aria-describedby="doc-title"]').nth(0), + ).toHaveText('My mocked document'); + }); +}); + +test.describe('Document grid item options', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('it pins a document', async ({ page, browserName }) => { + const [docTitle] = await createDoc(page, `Favorite doc`, browserName); + + await page.goto('/'); + + const row = await getGridRow(page, docTitle); + + // Pin + await row.getByText(`more_horiz`).click(); + await page.getByText('push_pin').click(); + + // Check is pinned + await expect(row.getByLabel('Pin document icon')).toBeVisible(); + const leftPanelFavorites = page.getByTestId('left-panel-favorites'); + await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible(); + + // Unpin + await row.getByText(`more_horiz`).click(); + await page.getByText('Unpin').click(); + + // Check is unpinned + await expect(row.getByLabel('Pin document icon')).toBeHidden(); + await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden(); + }); + + test('it deletes the document', async ({ page, browserName }) => { + const [docTitle] = await createDoc(page, `delete doc`, browserName); + + await page.goto('/'); + + await expect(page.getByText(docTitle)).toBeVisible(); + const row = await getGridRow(page, docTitle); + await row.getByText(`more_horiz`).click(); + + await page.getByRole('menuitem', { name: 'Remove' }).click(); + + await expect( + page.getByRole('heading', { name: 'Delete a doc' }), + ).toBeVisible(); + + await page + .getByRole('button', { + name: 'Confirm deletion', + }) + .click(); + + await expect( + page.getByText('The document has been deleted.'), + ).toBeVisible(); + + await expect(page.getByText(docTitle)).toBeHidden(); + }); + + test("it checks if the delete option is disabled if we don't have the destroy capability", async ({ + page, + }) => { + await page.route('*/**/api/v1.0/documents/?page=1', async (route) => { + await route.fulfill({ + json: { + results: [ + { + id: 'mocked-document-id', + content: '', + title: 'Mocked document', + accesses: [], + abilities: { + destroy: false, // Means not owner + link_configuration: false, + versions_destroy: false, + versions_list: true, + versions_retrieve: true, + accesses_manage: false, // Means not admin + update: false, + partial_update: false, // Means not editor + retrieve: true, + }, + link_reach: 'restricted', + created_at: '2021-09-01T09:00:00Z', + }, + ], + }, + }); + }); + await page.goto('/'); + + const button = page.getByTestId( + `docs-grid-actions-button-mocked-document-id`, + ); + await expect(button).toBeVisible(); + await button.click(); + const removeButton = page.getByTestId( + `docs-grid-actions-remove-mocked-document-id`, + ); + await expect(removeButton).toBeVisible(); + await removeButton.isDisabled(); + }); +}); + +test.describe('Documents filters', () => { + test('it checks the prebuild left panel filters', async ({ page }) => { + void page.goto('/'); + + // All Docs + const response = await page.waitForResponse( + (response) => + response.url().endsWith('documents/?page=1') && + response.status() === 200, + ); + const result = await response.json(); + const allCount = result.count as number; + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const allDocs = page.getByLabel('All docs'); + const myDocs = page.getByLabel('My docs'); + const sharedWithMe = page.getByLabel('Shared with me'); + + // Initial state + await expect(allDocs).toBeVisible(); + await expect(allDocs).toHaveAttribute('aria-selected', 'true'); + + await expect(myDocs).toBeVisible(); + await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(myDocs).toHaveAttribute('aria-selected', 'false'); + + await expect(sharedWithMe).toBeVisible(); + await expect(sharedWithMe).toHaveCSS( + 'background-color', + 'rgba(0, 0, 0, 0)', + ); + await expect(sharedWithMe).toHaveAttribute('aria-selected', 'false'); + + await allDocs.click(); + + let url = new URL(page.url()); + let target = url.searchParams.get('target'); + expect(target).toBe('all_docs'); + + // My docs + await myDocs.click(); + url = new URL(page.url()); + target = url.searchParams.get('target'); + expect(target).toBe('my_docs'); + const responseMyDocs = await page.waitForResponse( + (response) => + response.url().endsWith('documents/?page=1&is_creator_me=true') && + response.status() === 200, + ); + const resultMyDocs = await responseMyDocs.json(); + const countMyDocs = resultMyDocs.count as number; + await expect(page.getByTestId('grid-loader')).toBeHidden(); + expect(countMyDocs).toBeLessThanOrEqual(allCount); + + // Shared with me + await sharedWithMe.click(); + url = new URL(page.url()); + target = url.searchParams.get('target'); + expect(target).toBe('shared_with_me'); + const responseSharedWithMe = await page.waitForResponse( + (response) => + response.url().includes('documents/?page=1&is_creator_me=false') && + response.status() === 200, + ); + const resultSharedWithMe = await responseSharedWithMe.json(); + const countSharedWithMe = resultSharedWithMe.count as number; + await expect(page.getByTestId('grid-loader')).toBeHidden(); + expect(countSharedWithMe).toBeLessThanOrEqual(allCount); + expect(countSharedWithMe + countMyDocs).toEqual(allCount); + }); +}); + +test.describe('Documents Grid', () => { + test('checks all the elements are visible', async ({ page }) => { + void page.goto('/'); + + let docs: SmallDoc[] = []; + const response = await page.waitForResponse( + (response) => + response.url().endsWith('documents/?page=1') && + response.status() === 200, + ); + const result = await response.json(); + docs = result.results as SmallDoc[]; + + await expect(page.getByTestId('grid-loader')).toBeHidden(); + await expect(page.locator('h4').getByText('All docs')).toBeVisible(); + + const thead = page.getByTestId('docs-grid-header'); + await expect(thead.getByText(/Name/i)).toBeVisible(); + await expect(thead.getByText(/Updated at/i)).toBeVisible(); + + await Promise.all( + docs.map(async (doc) => { + await expect( + page.getByTestId(`docs-grid-name-${doc.id}`), + ).toBeVisible(); + }), + ); + }); + + test('checks the infinite scroll', async ({ page }) => { + let docs: SmallDoc[] = []; + const responsePromisePage1 = page.waitForResponse((response) => { + return ( + response.url().endsWith(`/documents/?page=1`) && + response.status() === 200 + ); + }); + + const responsePromisePage2 = page.waitForResponse( + (response) => + response.url().endsWith(`/documents/?page=2`) && + response.status() === 200, + ); + + await page.goto('/'); + + const responsePage1 = await responsePromisePage1; + expect(responsePage1.ok()).toBeTruthy(); + let result = await responsePage1.json(); + docs = result.results as SmallDoc[]; + await Promise.all( + docs.map(async (doc) => { + await expect( + page.getByTestId(`docs-grid-name-${doc.id}`), + ).toBeVisible(); + }), + ); + + await page.getByTestId('infinite-scroll-trigger').scrollIntoViewIfNeeded(); + const responsePage2 = await responsePromisePage2; + result = await responsePage2.json(); + docs = result.results as SmallDoc[]; + await Promise.all( + docs.map(async (doc) => { + await expect( + page.getByTestId(`docs-grid-name-${doc.id}`), + ).toBeVisible(); + }), + ); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts new file mode 100644 index 00000000..be1bfcad --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -0,0 +1,520 @@ +import { expect, test } from '@playwright/test'; + +import { + createDoc, + getGridRow, + goToGridDoc, + mockedAccesses, + mockedDocument, + mockedInvitations, + verifyDocName, +} from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Header', () => { + test('it checks the element are correctly displayed', async ({ page }) => { + await mockedDocument(page, { + accesses: [ + { + id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', + role: 'owner', + user: { + email: 'super@owner.com', + full_name: 'Super Owner', + }, + }, + { + id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', + role: 'admin', + user: { + email: 'super@admin.com', + }, + }, + { + id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', + role: 'owner', + user: { + email: 'super2@owner.com', + }, + }, + ], + abilities: { + destroy: true, // Means owner + link_configuration: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + accesses_manage: true, + accesses_view: true, + update: true, + partial_update: true, + retrieve: true, + }, + link_reach: 'public', + created_at: '2021-09-01T09:00:00Z', + }); + + await goToGridDoc(page); + + const card = page.getByLabel( + 'It is the card information about the document.', + ); + + const docTitle = card.getByRole('textbox', { name: 'doc title input' }); + await expect(docTitle).toBeVisible(); + + await expect(card.getByText('Public document')).toBeVisible(); + + await expect(card.getByText('Owner ·')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'download' })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Open the document options' }), + ).toBeVisible(); + }); + + test('it updates the title doc', async ({ page, browserName }) => { + await createDoc(page, 'doc-update', browserName, 1); + const docTitle = page.getByRole('textbox', { name: 'doc title input' }); + await expect(docTitle).toBeVisible(); + await docTitle.fill('Hello World'); + await docTitle.blur(); + await verifyDocName(page, 'Hello World'); + }); + + test('it deletes the doc', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1); + + await page.getByLabel('Open the document options').click(); + await page.getByLabel('Delete document').click(); + + await expect( + page.getByRole('heading', { name: 'Delete a doc' }), + ).toBeVisible(); + + await expect( + page.getByText(`Are you sure you want to delete this document ?`), + ).toBeVisible(); + + await page + .getByRole('button', { + name: 'Confirm deletion', + }) + .click(); + + await expect( + page.getByText('The document has been deleted.'), + ).toBeVisible(); + + await expect(page.getByRole('button', { name: 'New do' })).toBeVisible(); + + const row = page + .getByLabel('Datagrid of the documents page 1') + .getByRole('table') + .getByRole('row') + .filter({ + hasText: randomDoc, + }); + + expect(await row.count()).toBe(0); + }); + + test('it checks the options available if administrator', async ({ page }) => { + await mockedDocument(page, { + abilities: { + accesses_manage: true, // Means admin + accesses_view: true, + destroy: false, // Means not owner + link_configuration: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + update: true, + partial_update: true, + retrieve: true, + }, + }); + + await mockedInvitations(page); + await mockedAccesses(page); + + await goToGridDoc(page); + + await expect(page.getByRole('button', { name: 'download' })).toBeVisible(); + + await page.getByLabel('Open the document options').click(); + + await expect(page.getByLabel('Delete document')).toBeDisabled(); + + // Click somewhere else to close the options + await page.click('body', { position: { x: 0, y: 0 } }); + + await page.getByRole('button', { name: 'Share' }).click(); + + const shareModal = page.getByLabel('Share modal'); + await expect(shareModal).toBeVisible(); + await expect(page.getByText('Share the document')).toBeVisible(); + + await expect(page.getByPlaceholder('Type a name or email')).toBeVisible(); + + const invitationCard = shareModal.getByLabel('List invitation card'); + await expect(invitationCard).toBeVisible(); + await expect( + invitationCard.getByText('test@invitation.test').first(), + ).toBeVisible(); + await expect(invitationCard.getByLabel('doc-role-dropdown')).toBeVisible(); + + await invitationCard.getByRole('button', { name: 'more_horiz' }).click(); + + await expect(page.getByLabel('Delete')).toBeEnabled(); + await invitationCard.click(); + + const memberCard = shareModal.getByLabel('List members card'); + await expect(memberCard).toBeVisible(); + await expect( + memberCard.getByText('test@accesses.test').first(), + ).toBeVisible(); + await expect(memberCard.getByLabel('doc-role-dropdown')).toBeVisible(); + await expect( + memberCard.getByRole('button', { name: 'more_horiz' }), + ).toBeVisible(); + await memberCard.getByRole('button', { name: 'more_horiz' }).click(); + + await expect(page.getByLabel('Delete')).toBeEnabled(); + }); + + test('it checks the options available if editor', async ({ page }) => { + await mockedDocument(page, { + abilities: { + accesses_manage: false, // Means not admin + accesses_view: true, + destroy: false, // Means not owner + link_configuration: false, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + update: true, + partial_update: true, // Means editor + retrieve: true, + }, + }); + + await mockedInvitations(page, { + abilities: { + destroy: false, + update: false, + partial_update: false, + retrieve: true, + }, + }); + await mockedAccesses(page); + + await goToGridDoc(page); + + await expect(page.getByRole('button', { name: 'download' })).toBeVisible(); + await page.getByLabel('Open the document options').click(); + + await expect(page.getByLabel('Delete document')).toBeDisabled(); + + // Click somewhere else to close the options + await page.click('body', { position: { x: 0, y: 0 } }); + + await page.getByRole('button', { name: 'Share' }).click(); + + const shareModal = page.getByLabel('Share modal'); + await expect(page.getByText('Share the document')).toBeVisible(); + + await expect(page.getByPlaceholder('Type a name or email')).toBeHidden(); + + const invitationCard = shareModal.getByLabel('List invitation card'); + await expect( + invitationCard.getByText('test@invitation.test').first(), + ).toBeVisible(); + await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible(); + await expect( + invitationCard.getByRole('button', { name: 'more_horiz' }), + ).toBeHidden(); + + const memberCard = shareModal.getByLabel('List members card'); + await expect(memberCard.getByText('test@accesses.test')).toBeVisible(); + await expect(memberCard.getByLabel('doc-role-text')).toBeVisible(); + await expect( + memberCard.getByRole('button', { name: 'more_horiz' }), + ).toBeHidden(); + }); + + test('it checks the options available if reader', async ({ page }) => { + await mockedDocument(page, { + abilities: { + accesses_manage: false, // Means not admin + accesses_view: true, + destroy: false, // Means not owner + link_configuration: false, + versions_destroy: false, + versions_list: true, + versions_retrieve: true, + update: false, + partial_update: false, // Means not editor + retrieve: true, + }, + }); + + await mockedInvitations(page, { + abilities: { + destroy: false, + update: false, + partial_update: false, + retrieve: true, + }, + }); + await mockedAccesses(page); + + await goToGridDoc(page); + + await expect(page.getByRole('button', { name: 'download' })).toBeVisible(); + await page.getByLabel('Open the document options').click(); + + await expect(page.getByLabel('Delete document')).toBeDisabled(); + + // Click somewhere else to close the options + await page.click('body', { position: { x: 0, y: 0 } }); + + await page.getByRole('button', { name: 'Share' }).click(); + + const shareModal = page.getByLabel('Share modal'); + await expect(page.getByText('Share the document')).toBeVisible(); + + await expect(page.getByPlaceholder('Type a name or email')).toBeHidden(); + + const invitationCard = shareModal.getByLabel('List invitation card'); + await expect( + invitationCard.getByText('test@invitation.test').first(), + ).toBeVisible(); + await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible(); + await expect( + invitationCard.getByRole('button', { name: 'more_horiz' }), + ).toBeHidden(); + + const memberCard = shareModal.getByLabel('List members card'); + await expect(memberCard.getByText('test@accesses.test')).toBeVisible(); + await expect(memberCard.getByLabel('doc-role-text')).toBeVisible(); + await expect( + memberCard.getByRole('button', { name: 'more_horiz' }), + ).toBeHidden(); + }); + + test('It checks the copy as Markdown button', async ({ + page, + browserName, + }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + browserName === 'webkit', + 'navigator.clipboard is not working with webkit and playwright', + ); + + // create page and navigate to it + await page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + // Add dummy content to the doc + const editor = page.locator('.ProseMirror'); + const docFirstBlock = editor.locator('.bn-block-content').first(); + await docFirstBlock.click(); + await page.keyboard.type('# Hello World', { delay: 100 }); + const docFirstBlockContent = docFirstBlock.locator('h1'); + await expect(docFirstBlockContent).toHaveText('Hello World'); + + // Copy content to clipboard + await page.getByLabel('Open the document options').click(); + await page.getByLabel('Copy as Markdown').click(); + await expect(page.getByText('Copied to clipboard')).toBeVisible(); + + // Test that clipboard is in Markdown format + const handle = await page.evaluateHandle(() => + navigator.clipboard.readText(), + ); + const clipboardContent = await handle.jsonValue(); + expect(clipboardContent.trim()).toBe('# Hello World'); + }); + + test('It checks the copy as HTML button', async ({ page, browserName }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + browserName === 'webkit', + 'navigator.clipboard is not working with webkit and playwright', + ); + + // create page and navigate to it + await page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + // Add dummy content to the doc + const editor = page.locator('.ProseMirror'); + const docFirstBlock = editor.locator('.bn-block-content').first(); + await docFirstBlock.click(); + await page.keyboard.type('# Hello World', { delay: 100 }); + const docFirstBlockContent = docFirstBlock.locator('h1'); + await expect(docFirstBlockContent).toHaveText('Hello World'); + + // Copy content to clipboard + await page.getByLabel('Open the document options').click(); + await page.getByLabel('Copy as HTML').click(); + await expect(page.getByText('Copied to clipboard')).toBeVisible(); + + // Test that clipboard is in HTML format + const handle = await page.evaluateHandle(() => + navigator.clipboard.readText(), + ); + const clipboardContent = await handle.jsonValue(); + expect(clipboardContent.trim()).toBe(`

Hello World

`); + }); + + test('it checks the copy link button', async ({ page, browserName }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + browserName === 'webkit', + 'navigator.clipboard is not working with webkit and playwright', + ); + await mockedDocument(page, { + abilities: { + destroy: false, // Means owner + link_configuration: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + accesses_manage: false, + accesses_view: false, + update: true, + partial_update: true, + retrieve: true, + }, + }); + + await goToGridDoc(page); + + const shareButton = page.getByRole('button', { + name: 'Share', + exact: true, + }); + await expect(shareButton).toBeVisible(); + + await shareButton.click(); + await page.getByRole('button', { name: 'Copy link' }).click(); + await expect(page.getByText('Link Copied !')).toBeVisible(); + + const handle = await page.evaluateHandle(() => + navigator.clipboard.readText(), + ); + const clipboardContent = await handle.jsonValue(); + + const origin = await page.evaluate(() => window.location.origin); + expect(clipboardContent.trim()).toMatch( + `${origin}/docs/mocked-document-id/`, + ); + }); + + test('it pins a document', async ({ page, browserName }) => { + const [docTitle] = await createDoc(page, `Favorite doc`, browserName); + + await page.getByLabel('Open the document options').click(); + + // Pin + await page.getByText('push_pin').click(); + await page.getByLabel('Open the document options').click(); + await expect(page.getByText('Unpin')).toBeVisible(); + + await page.goto('/'); + + const row = await getGridRow(page, docTitle); + + // Check is pinned + await expect(row.getByLabel('Pin document icon')).toBeVisible(); + const leftPanelFavorites = page.getByTestId('left-panel-favorites'); + await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible(); + + await row.getByText(docTitle).click(); + await page.getByLabel('Open the document options').click(); + + // Unpin + await page.getByText('Unpin').click(); + await page.getByLabel('Open the document options').click(); + await expect(page.getByText('push_pin')).toBeVisible(); + + await page.goto('/'); + + // Check is unpinned + await expect(row.getByLabel('Pin document icon')).toBeHidden(); + await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden(); + }); +}); + +test.describe('Documents Header mobile', () => { + test.use({ viewport: { width: 500, height: 1200 } }); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('it checks the copy link button is displayed', async ({ page }) => { + await mockedDocument(page, { + abilities: { + destroy: false, + link_configuration: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + accesses_manage: false, + accesses_view: false, + update: true, + partial_update: true, + retrieve: true, + }, + }); + + await goToGridDoc(page); + + await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden(); + await page.getByLabel('Open the document options').click(); + await expect( + page.getByRole('menuitem', { name: 'Copy link' }), + ).toBeVisible(); + await page.getByLabel('Share').click(); + await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible(); + }); + + test('it checks the close button on Share modal', async ({ page }) => { + await mockedDocument(page, { + abilities: { + destroy: true, // Means owner + link_configuration: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + accesses_manage: true, + accesses_view: true, + update: true, + partial_update: true, + retrieve: true, + }, + }); + + await goToGridDoc(page); + + await page.getByLabel('Open the document options').click(); + await page.getByLabel('Share').click(); + + await expect(page.getByLabel('Share modal')).toBeVisible(); + await page.getByRole('button', { name: 'close' }).click(); + await expect(page.getByLabel('Share modal')).toBeHidden(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts new file mode 100644 index 00000000..f47d0918 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, randomName } from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Document create member', () => { + test('it selects 2 users and 1 invitation', async ({ page, browserName }) => { + const inputFill = 'user '; + const responsePromise = page.waitForResponse( + (response) => + response.url().includes(`/users/?q=${encodeURIComponent(inputFill)}`) && + response.status() === 200, + ); + await createDoc(page, 'select-multi-users', browserName, 1); + + await page.getByRole('button', { name: 'Share' }).click(); + + const inputSearch = page.getByRole('combobox', { + name: 'Quick search input', + }); + await expect(inputSearch).toBeVisible(); + + // Select user 1 and verify tag + await inputSearch.fill(inputFill); + const response = await responsePromise; + const users = (await response.json()) as { + email: string; + full_name?: string | null; + }[]; + + const list = page.getByTestId('doc-share-add-member-list'); + await expect(list).toBeHidden(); + const quickSearchContent = page.getByTestId('doc-share-quick-search'); + await quickSearchContent + .getByTestId(`search-user-row-${users[0].email}`) + .click(); + + await expect(list).toBeVisible(); + await expect( + list.getByTestId(`doc-share-add-member-${users[0].email}`), + ).toBeVisible(); + await expect( + list.getByText(`${users[0].full_name || users[0].email}`), + ).toBeVisible(); + + // Select user 2 and verify tag + await inputSearch.fill(inputFill); + await quickSearchContent + .getByTestId(`search-user-row-${users[1].email}`) + .click(); + + await expect( + list.getByTestId(`doc-share-add-member-${users[1].email}`), + ).toBeVisible(); + await expect( + list.getByText(`${users[1].full_name || users[1].email}`), + ).toBeVisible(); + + // Select email and verify tag + const email = randomName('test@test.fr', browserName, 1)[0]; + await inputSearch.fill(email); + await quickSearchContent.getByText(email).click(); + await expect(list.getByText(email)).toBeVisible(); + + // Check roles are displayed + await list.getByLabel('doc-role-dropdown').click(); + await expect(page.getByLabel('Reader')).toBeVisible(); + await expect(page.getByLabel('Editor')).toBeVisible(); + await expect(page.getByLabel('Owner')).toBeVisible(); + await expect(page.getByLabel('Administrator')).toBeVisible(); + + // Validate + await page.getByLabel('Administrator').click(); + await page.getByRole('button', { name: 'Invite' }).click(); + + // Check invitation added + await expect( + quickSearchContent.getByText('Pending invitations'), + ).toBeVisible(); + await expect(quickSearchContent.getByText(email).first()).toBeVisible(); + + // Check user added + await expect(page.getByText('Share with 3 users')).toBeVisible(); + await expect( + quickSearchContent + .getByText(users[0].full_name || users[0].email) + .first(), + ).toBeVisible(); + await expect( + quickSearchContent.getByText(users[0].email).first(), + ).toBeVisible(); + await expect( + quickSearchContent.getByText(users[1].email).first(), + ).toBeVisible(); + await expect( + quickSearchContent + .getByText(users[1].full_name || users[1].email) + .first(), + ).toBeVisible(); + }); + + test('it try to add twice the same invitation', async ({ + page, + browserName, + }) => { + await createDoc(page, 'invitation-twice', browserName, 1); + + await page.getByRole('button', { name: 'Share' }).click(); + + const inputSearch = page.getByRole('combobox', { + name: 'Quick search input', + }); + + const [email] = randomName('test@test.fr', browserName, 1); + await inputSearch.fill(email); + await page.getByTestId(`search-user-row-${email}`).click(); + + // Choose a role + const container = page.getByTestId('doc-share-add-member-list'); + await container.getByLabel('doc-role-dropdown').click(); + await page.getByLabel('Owner').click(); + + const responsePromiseCreateInvitation = page.waitForResponse( + (response) => + response.url().includes('/invitations/') && response.status() === 201, + ); + await page.getByRole('button', { name: 'Invite' }).click(); + + // Check invitation sent + + const responseCreateInvitation = await responsePromiseCreateInvitation; + expect(responseCreateInvitation.ok()).toBeTruthy(); + + await inputSearch.fill(email); + await page.getByTestId(`search-user-row-${email}`).click(); + + // Choose a role + await container.getByLabel('doc-role-dropdown').click(); + await page.getByLabel('Owner').click(); + + const responsePromiseCreateInvitationFail = page.waitForResponse( + (response) => + response.url().includes('/invitations/') && response.status() === 400, + ); + + await page.getByRole('button', { name: 'Invite' }).click(); + await expect( + page.getByText(`"${email}" is already invited to the document.`), + ).toBeVisible(); + const responseCreateInvitationFail = + await responsePromiseCreateInvitationFail; + expect(responseCreateInvitationFail.ok()).toBeFalsy(); + }); + + test('it manages invitation', async ({ page, browserName }) => { + await createDoc(page, 'user-invitation', browserName, 1); + + await page.getByRole('button', { name: 'Share' }).click(); + + const inputSearch = page.getByRole('combobox', { + name: 'Quick search input', + }); + + const email = randomName('test@test.fr', browserName, 1)[0]; + await inputSearch.fill(email); + await page.getByTestId(`search-user-row-${email}`).click(); + + // Choose a role + const container = page.getByTestId('doc-share-add-member-list'); + await container.getByLabel('doc-role-dropdown').click(); + await page.getByLabel('Administrator').click(); + + const responsePromiseCreateInvitation = page.waitForResponse( + (response) => + response.url().includes('/invitations/') && response.status() === 201, + ); + + await page.getByRole('button', { name: 'Invite' }).click(); + + // Check invitation sent + const responseCreateInvitation = await responsePromiseCreateInvitation; + expect(responseCreateInvitation.ok()).toBeTruthy(); + + const listInvitation = page.getByTestId('doc-share-quick-search'); + const userInvitation = listInvitation.getByTestId( + `doc-share-invitation-row-${email}`, + ); + await expect(userInvitation).toBeVisible(); + + await userInvitation.getByLabel('doc-role-dropdown').click(); + await page.getByLabel('Reader').click(); + + const moreActions = userInvitation.getByRole('button', { + name: 'more_horiz', + }); + await moreActions.click(); + + await page.getByLabel('Delete').click(); + + await expect(userInvitation).toBeHidden(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts new file mode 100644 index 00000000..45f68b78 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts @@ -0,0 +1,222 @@ +import { expect, test } from '@playwright/test'; + +import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Document list members', () => { + test('it checks a big list of members', async ({ page }) => { + await page.route( + /.*\/documents\/.*\/accesses\/\?page=.*/, + async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pageId = url.searchParams.get('page') ?? '1'; + + const accesses = { + count: 40, + next: +pageId < 2 ? 'http://anything/?page=2' : undefined, + previous: null, + results: Array.from({ length: 20 }, (_, i) => ({ + id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`, + user: { + id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`, + email: `impress@impress.world-page-${pageId}-${i}`, + full_name: `Impress World Page ${pageId}-${i}`, + }, + team: '', + role: 'editor', + abilities: { + destroy: false, + partial_update: true, + set_role_to: [], + }, + })), + }; + + if (request.method().includes('GET')) { + await route.fulfill({ + json: accesses, + }); + } else { + await route.continue(); + } + }, + ); + + const docTitle = await goToGridDoc(page); + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + + const prefix = 'doc-share-member-row'; + const elements = page.locator(`[data-testid^="${prefix}"]`); + const loadMore = page.getByTestId('load-more-members'); + + await expect(elements).toHaveCount(20); + await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible(); + + await loadMore.click(); + await expect(elements).toHaveCount(40); + await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible(); + + await expect(loadMore).toBeHidden(); + }); + + test('it checks a big list of invitations', async ({ page }) => { + await page.route( + /.*\/documents\/.*\/invitations\/\?page=.*/, + async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pageId = url.searchParams.get('page') ?? '1'; + const accesses = { + count: 40, + next: +pageId < 2 ? 'http://anything/?page=2' : null, + previous: null, + results: Array.from({ length: 20 }, (_, i) => ({ + id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`, + email: `impress@impress.world-page-${pageId}-${i}`, + team: '', + role: 'editor', + abilities: { + destroy: true, + update: true, + partial_update: true, + retrieve: true, + }, + })), + }; + + if (request.method().includes('GET')) { + await route.fulfill({ + json: accesses, + }); + } else { + await route.continue(); + } + }, + ); + + const docTitle = await goToGridDoc(page); + await verifyDocName(page, docTitle); + await page.getByRole('button', { name: 'Share' }).click(); + + const prefix = 'doc-share-invitation'; + const elements = page.locator(`[data-testid^="${prefix}"]`); + const loadMore = page.getByTestId('load-more-invitations'); + + await expect(elements).toHaveCount(20); + await expect( + page.getByText(`impress@impress.world-page-1-16`).first(), + ).toBeVisible(); + + await loadMore.click(); + await expect(elements).toHaveCount(40); + await expect( + page.getByText(`impress@impress.world-page-2-16`).first(), + ).toBeVisible(); + + await expect(loadMore).toBeHidden(); + }); + + test('it checks the role rules', async ({ page, browserName }) => { + const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + const list = page.getByTestId('doc-share-quick-search'); + await expect(list).toBeVisible(); + const currentUser = list.getByTestId( + `doc-share-member-row-user@${browserName}.e2e`, + ); + const currentUserRole = currentUser.getByLabel('doc-role-dropdown'); + await expect(currentUser).toBeVisible(); + await expect(currentUserRole).toBeVisible(); + await currentUserRole.click(); + const soloOwner = page.getByText( + `You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`, + ); + await expect(soloOwner).toBeVisible(); + await list.click(); + const newUserEmail = await addNewMember(page, 0, 'Owner'); + const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`); + const newUserRoles = newUser.getByLabel('doc-role-dropdown'); + + await expect(newUser).toBeVisible(); + + await currentUserRole.click(); + await expect(soloOwner).toBeHidden(); + await list.click(); + + await newUserRoles.click(); + await list.click(); + + await currentUserRole.click(); + await page.getByLabel('Administrator').click(); + await list.click(); + await expect(currentUserRole).toBeVisible(); + + await currentUserRole.click(); + await page.getByLabel('Reader').click(); + await list.click(); + await expect(currentUserRole).toBeHidden(); + }); + + test('it checks the delete members', async ({ page, browserName }) => { + const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + + const list = page.getByTestId('doc-share-quick-search'); + + const emailMyself = `user@${browserName}.e2e`; + const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`); + const mySelfMoreActions = mySelf.getByRole('button', { + name: 'more_horiz', + }); + + const userOwnerEmail = await addNewMember(page, 0, 'Owner'); + const userOwner = list.getByTestId( + `doc-share-member-row-${userOwnerEmail}`, + ); + const userOwnerMoreActions = userOwner.getByRole('button', { + name: 'more_horiz', + }); + + await page.getByRole('button', { name: 'close' }).first().click(); + await page.getByRole('button', { name: 'Share' }).first().click(); + + const userReaderEmail = await addNewMember(page, 0, 'Reader'); + + const userReader = list.getByTestId( + `doc-share-member-row-${userReaderEmail}`, + ); + const userReaderMoreActions = userReader.getByRole('button', { + name: 'more_horiz', + }); + + await expect(mySelf).toBeVisible(); + await expect(userOwner).toBeVisible(); + await expect(userReader).toBeVisible(); + + await expect(userOwnerMoreActions).toBeVisible(); + await expect(userReaderMoreActions).toBeVisible(); + await expect(mySelfMoreActions).toBeVisible(); + + await userReaderMoreActions.click(); + await page.getByLabel('Delete').click(); + await expect(userReader).toBeHidden(); + + await mySelfMoreActions.click(); + await page.getByLabel('Delete').click(); + await expect( + page.getByText('You do not have permission to view this document.'), + ).toBeVisible(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts new file mode 100644 index 00000000..9aebb408 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts @@ -0,0 +1,120 @@ +import crypto from 'crypto'; + +import { expect, test } from '@playwright/test'; + +import { + createDoc, + expectLoginPage, + keyCloakSignIn, + mockedDocument, + verifyDocName, +} from './common'; + +test.describe('Doc Routing', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('Check the presence of the meta tag noindex', async ({ page }) => { + const buttonCreateHomepage = page.getByRole('button', { + name: 'New doc', + }); + + await expect(buttonCreateHomepage).toBeVisible(); + await buttonCreateHomepage.click(); + await expect( + page.getByRole('button', { + name: 'Share', + }), + ).toBeVisible(); + const metaDescription = page.locator('meta[name="robots"]'); + await expect(metaDescription).toHaveAttribute('content', 'noindex'); + }); + + test('checks alias docs url with homepage', async ({ page }) => { + await expect(page).toHaveURL('/'); + + const buttonCreateHomepage = page.getByRole('button', { + name: 'New doc', + }); + + await expect(buttonCreateHomepage).toBeVisible(); + + await page.goto('/docs/'); + await expect(buttonCreateHomepage).toBeVisible(); + await expect(page).toHaveURL(/\/docs\/$/); + }); + + test('checks 404 on docs/[id] page', async ({ page }) => { + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(300); + + await page.goto('/docs/some-unknown-doc'); + await expect( + page.getByText( + 'It seems that the page you are looking for does not exist or cannot be displayed correctly.', + ), + ).toBeVisible({ + timeout: 15000, + }); + }); + + test('checks 401 on docs/[id] page', async ({ page, browserName }) => { + const [docTitle] = await createDoc(page, 'My new doc', browserName, 1); + await verifyDocName(page, docTitle); + + const responsePromise = page.route( + /.*\/link-configuration\/$|users\/me\/$/, + async (route) => { + const request = route.request(); + + if ( + request.method().includes('PUT') || + request.method().includes('GET') + ) { + await route.fulfill({ + status: 401, + json: { + detail: 'Log in to access the document', + }, + }); + } else { + await route.continue(); + } + }, + ); + + await page.getByRole('button', { name: 'Share' }).click(); + + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + await page.getByLabel('Connected').click(); + + await responsePromise; + + await expect(page.getByText('Log in to access the document')).toBeVisible(); + }); +}); + +test.describe('Doc Routing: Not loggued', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('checks redirect to a doc after login', async ({ + page, + browserName, + }) => { + const uuid = crypto.randomUUID(); + await mockedDocument(page, { link_reach: 'public', id: uuid }); + await page.goto(`/docs/${uuid}/`); + await expect(page.locator('h2').getByText('Mocked document')).toBeVisible(); + await page.getByRole('button', { name: 'Login' }).click(); + await keyCloakSignIn(page, browserName, false); + await expect(page.locator('h2').getByText('Mocked document')).toBeVisible(); + }); + + // eslint-disable-next-line playwright/expect-expect + test('The homepage redirects to login.', async ({ page }) => { + await page.goto('/'); + await expectLoginPage(page); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts new file mode 100644 index 00000000..27088c93 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, verifyDocName } from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Document search', () => { + test('it searches documents', async ({ page, browserName }) => { + const [doc1Title] = await createDoc( + page, + 'My doc search super', + browserName, + 1, + ); + await verifyDocName(page, doc1Title); + await page.goto('/'); + + const [doc2Title] = await createDoc( + page, + 'My doc search doc', + browserName, + 1, + ); + await verifyDocName(page, doc2Title); + await page.goto('/'); + await page.getByRole('button', { name: 'search' }).click(); + + await expect( + page.getByRole('img', { name: 'No active search' }), + ).toBeVisible(); + + await expect( + page.getByLabel('Search modal').getByText('search'), + ).toBeVisible(); + + const inputSearch = page.getByPlaceholder('Type the name of a document'); + + await inputSearch.click(); + await inputSearch.fill('My doc search'); + await inputSearch.press('ArrowDown'); + + const listSearch = page.getByRole('listbox').getByRole('group'); + const rowdoc = listSearch.getByRole('option').first(); + await expect(rowdoc.getByText('keyboard_return')).toBeVisible(); + await expect(rowdoc.getByText(/seconds? ago/)).toBeVisible(); + + await expect( + listSearch.getByRole('option').getByText(doc1Title), + ).toBeVisible(); + await expect( + listSearch.getByRole('option').getByText(doc2Title), + ).toBeVisible(); + + await inputSearch.fill('My doc search super'); + + await expect( + listSearch.getByRole('option').getByText(doc1Title), + ).toBeVisible(); + + await expect( + listSearch.getByRole('option').getByText(doc2Title), + ).toBeHidden(); + }); + + test('it checks cmd+k modal search interaction', async ({ + page, + browserName, + }) => { + const [doc1Title] = await createDoc( + page, + 'Doc seack ctrl k', + browserName, + 1, + ); + await verifyDocName(page, doc1Title); + + await page.keyboard.press('Control+k'); + await expect( + page.getByLabel('Search modal').getByText('search'), + ).toBeVisible(); + + await page.keyboard.press('Escape'); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('Hello world'); + await editor.getByText('Hello world').selectText(); + + await page.keyboard.press('Control+k'); + await expect(page.getByRole('textbox', { name: 'Edit URL' })).toBeVisible(); + await expect( + page.getByLabel('Search modal').getByText('search'), + ).toBeHidden(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-table-content.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-table-content.spec.ts new file mode 100644 index 00000000..2f4c4240 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-table-content.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, verifyDocName } from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Table Content', () => { + test('it checks the doc table content', async ({ page, browserName }) => { + test.setTimeout(60000); + + const [randomDoc] = await createDoc( + page, + 'doc-table-content', + browserName, + 1, + ); + + await verifyDocName(page, randomDoc); + + await page.locator('.ProseMirror').click(); + + await page.keyboard.type('# Level 1\n## Level 2\n### Level 3'); + + const summaryContainer = page.locator('#summaryContainer'); + await summaryContainer.click(); + + const level1 = summaryContainer.getByText('Level 1'); + const level2 = summaryContainer.getByText('Level 2'); + const level3 = summaryContainer.getByText('Level 3'); + + await expect(level1).toBeVisible(); + await expect(level1).toHaveCSS('padding', /4px 0px/); + await expect(level1).toHaveAttribute('aria-selected', 'true'); + + await expect(level2).toBeVisible(); + await expect(level2).toHaveCSS('padding-left', /14.4px/); + await expect(level2).toHaveAttribute('aria-selected', 'false'); + + await expect(level3).toBeVisible(); + await expect(level3).toHaveCSS('padding-left', /24px/); + await expect(level3).toHaveAttribute('aria-selected', 'false'); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts new file mode 100644 index 00000000..913c88ec --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from '@playwright/test'; + +import { + createDoc, + goToGridDoc, + mockedDocument, + verifyDocName, +} from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Version', () => { + test('it displays the doc versions', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1); + + await verifyDocName(page, randomDoc); + + await page.getByLabel('Open the document options').click(); + await page.getByLabel('Version history').click(); + await expect(page.getByText('History', { exact: true })).toBeVisible(); + + const modal = page.getByLabel('version history modal'); + const panel = modal.getByLabel('version list'); + await expect(panel).toBeVisible(); + await expect(modal.getByText('No versions')).toBeVisible(); + + const editor = page.locator('.ProseMirror'); + await modal.getByRole('button', { name: 'close' }).click(); + await editor.click(); + await page.keyboard.type('# Hello World'); + + await goToGridDoc(page, { + title: randomDoc, + }); + + await expect( + page.getByRole('heading', { name: 'Hello World' }), + ).toBeVisible(); + + await page + .locator('.ProseMirror .bn-block') + .getByRole('heading', { name: 'Hello World' }) + .fill('It will create a version'); + + await goToGridDoc(page, { + title: randomDoc, + }); + + await expect(page.getByText('Hello World')).toBeHidden(); + await expect( + page.getByRole('heading', { name: 'It will create a version' }), + ).toBeVisible(); + + await page.getByLabel('Open the document options').click(); + await page.getByLabel('Version history').click(); + + await expect(panel).toBeVisible(); + await expect(page.getByText('History', { exact: true })).toBeVisible(); + await expect(page.getByRole('status')).toBeHidden(); + const items = await panel.locator('.version-item').all(); + expect(items.length).toBe(1); + await items[0].click(); + + await expect(modal.getByText('Hello World')).toBeVisible(); + await expect(modal.getByText('It will create a version')).toBeHidden(); + }); + + test('it does not display the doc versions if not allowed', async ({ + page, + }) => { + await mockedDocument(page, { + abilities: { + versions_list: false, + partial_update: true, + }, + }); + + await goToGridDoc(page); + + await verifyDocName(page, 'Mocked document'); + + await page.getByLabel('Open the document options').click(); + await expect(page.getByLabel('Version history')).toBeDisabled(); + }); + + test('it restores the doc version', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1); + await verifyDocName(page, randomDoc); + + await page.locator('.bn-block-outer').last().click(); + await page.locator('.bn-block-outer').last().fill('Hello'); + + await goToGridDoc(page, { + title: randomDoc, + }); + + const editor = page.locator('.ProseMirror'); + await expect(editor.getByText('Hello')).toBeVisible(); + await page.locator('.bn-block-outer').last().click(); + await page.keyboard.press('Enter'); + await page.locator('.bn-block-outer').last().fill('World'); + + await goToGridDoc(page, { + title: randomDoc, + }); + + await expect(page.getByText('World')).toBeVisible(); + + await page.getByLabel('Open the document options').click(); + await page.getByLabel('Version history').click(); + + const modal = page.getByLabel('version history modal'); + const panel = modal.getByLabel('version list'); + await expect(panel).toBeVisible(); + + await expect(page.getByText('History', { exact: true })).toBeVisible(); + await expect(page.getByRole('status')).toBeVisible(); + await expect(page.getByRole('status')).toBeHidden(); + const items = await panel.locator('.version-item').all(); + expect(items.length).toBe(1); + await items[0].click(); + + await expect(modal.getByText('World')).toBeHidden(); + + await page.getByRole('button', { name: 'Restore' }).click(); + await expect(page.getByText('Your current document will')).toBeVisible(); + await page.getByText('If a member is editing, his').click(); + + await page.getByLabel('Restore', { exact: true }).click(); + + await expect(page.getByText('Hello')).toBeVisible(); + await expect(page.getByText('World')).toBeHidden(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts new file mode 100644 index 00000000..f28fb38d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts @@ -0,0 +1,507 @@ +import { expect, test } from '@playwright/test'; + +import { + createDoc, + expectLoginPage, + keyCloakSignIn, + verifyDocName, +} from './common'; + +const browsersName = ['chromium', 'webkit', 'firefox']; + +test.describe('Doc Visibility', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('It checks the copy link button', async ({ page, browserName }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + browserName === 'webkit', + 'navigator.clipboard is not working with webkit and playwright', + ); + + await createDoc(page, 'My button copy doc', browserName, 1); + + await page.getByRole('button', { name: 'Share' }).click(); + await page.getByRole('button', { name: 'Copy link' }).click(); + + await expect(page.getByText('Link Copied !')).toBeVisible(); + + const handle = await page.evaluateHandle(() => + navigator.clipboard.readText(), + ); + const clipboardContent = await handle.jsonValue(); + + expect(clipboardContent).toMatch(page.url()); + }); + + test('It checks the link role options', async ({ page, browserName }) => { + await createDoc(page, 'Doc role options', browserName, 1); + + await page.getByRole('button', { name: 'Share' }).click(); + + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + + await expect(selectVisibility.getByText('Private')).toBeVisible(); + + await expect(page.getByLabel('Read only')).toBeHidden(); + await expect(page.getByLabel('Can read and edit')).toBeHidden(); + + await selectVisibility.click(); + await page.getByLabel('Connected').click(); + + await expect(page.getByLabel('Visibility mode')).toBeVisible(); + + await selectVisibility.click(); + + await page.getByLabel('Public', { exact: true }).click(); + + await expect(page.getByLabel('Visibility mode')).toBeVisible(); + }); +}); + +test.describe('Doc Visibility: Restricted', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('A doc is not accessible when not authentified.', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc( + page, + 'Restricted no auth', + browserName, + 1, + ); + + await verifyDocName(page, docTitle); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect( + page.getByText('Log in to access the document.'), + ).toBeVisible(); + }); + + test('A doc is not accessible when authentified but not member.', async ({ + page, + browserName, + }) => { + test.slow(); + + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1); + + await verifyDocName(page, docTitle); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + const otherBrowser = browsersName.find((b) => b !== browserName); + + await keyCloakSignIn(page, otherBrowser!); + + await expect( + page.getByRole('link', { name: 'Docs Logo Docs' }), + ).toBeVisible(); + + await page.goto(urlDoc); + + await expect( + page.getByText('You do not have permission to view this document.'), + ).toBeVisible({ + timeout: 10000, + }); + }); + + test('A doc is accessible when member.', async ({ page, browserName }) => { + test.slow(); + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + + const inputSearch = page.getByRole('combobox', { + name: 'Quick search input', + }); + + const otherBrowser = browsersName.find((b) => b !== browserName); + const username = `user@${otherBrowser}.e2e`; + await inputSearch.fill(username); + await page.getByRole('option', { name: username }).click(); + + // Choose a role + const container = page.getByTestId('doc-share-add-member-list'); + await container.getByLabel('doc-role-dropdown').click(); + await page.getByLabel('Reader').click(); + + await page.getByRole('button', { name: 'Invite' }).click(); + + await page.locator('.c__modal__backdrop').click({ + position: { x: 0, y: 0 }, + }); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await keyCloakSignIn(page, otherBrowser!); + + await expect( + page.getByRole('link', { name: 'Docs Logo Docs' }), + ).toBeVisible(); + + await page.goto(urlDoc); + + await verifyDocName(page, docTitle); + await expect(page.getByLabel('Share button')).toBeVisible(); + }); +}); + +test.describe('Doc Visibility: Public', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('It checks a public doc in read only mode', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc( + page, + 'Public read only', + browserName, + 1, + ); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await expect(page.getByLabel('Visibility mode')).toBeVisible(); + await page.getByLabel('Visibility mode').click(); + await page + .getByRole('menuitem', { + name: 'Reading', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.').first(), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const cardContainer = page.getByLabel( + 'It is the card information about the document.', + ); + + await expect(cardContainer.getByTestId('public-icon')).toBeVisible(); + + await expect( + cardContainer.getByText('Public document', { exact: true }), + ).toBeVisible(); + + await expect(page.getByRole('button', { name: 'search' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible(); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docTitle)).toBeVisible(); + await expect(page.getByRole('button', { name: 'search' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); + const card = page.getByLabel('It is the card information'); + await expect(card).toBeVisible(); + + await expect(card.getByText('Reader')).toBeVisible(); + }); + + test('It checks a public doc in editable mode', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc(page, 'Public editable', browserName, 1); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByLabel('Visibility mode').click(); + await page.getByLabel('Edition').click(); + + await expect( + page.getByText('The document visibility has been updated.').first(), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const cardContainer = page.getByLabel( + 'It is the card information about the document.', + ); + + await expect(cardContainer.getByTestId('public-icon')).toBeVisible(); + + await expect( + cardContainer.getByText('Public document', { exact: true }), + ).toBeVisible(); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await verifyDocName(page, docTitle); + await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); + }); +}); + +test.describe('Doc Visibility: Authenticated', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('A doc is not accessible when unauthentified.', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc( + page, + 'Authenticated unauthentified', + browserName, + 1, + ); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + await page + .getByRole('menuitem', { + name: 'Connected', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docTitle)).toBeHidden(); + + await expect( + page.getByText('Log in to access the document.'), + ).toBeVisible(); + }); + + test('It checks a authenticated doc in read only mode', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc( + page, + 'Authenticated read only', + browserName, + 1, + ); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + await page + .getByRole('menuitem', { + name: 'Connected', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await expect( + page + .getByLabel('It is the card information about the document.') + .getByText('Document accessible to any connected person', { + exact: true, + }), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + const otherBrowser = browsersName.find((b) => b !== browserName); + await keyCloakSignIn(page, otherBrowser!); + + await expect( + page.getByRole('link', { name: 'Docs Logo Docs' }), + ).toBeVisible(); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docTitle)).toBeVisible(); + await page.getByRole('button', { name: 'Share' }).click(); + await page.getByRole('button', { name: 'Copy link' }).click(); + await expect(page.getByText('Link Copied !')).toBeVisible(); + }); + + test('It checks a authenticated doc in editable mode', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc( + page, + 'Authenticated editable', + browserName, + 1, + ); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + await page + .getByRole('menuitem', { + name: 'Connected', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + const urlDoc = page.url(); + await page.getByLabel('Visibility mode').click(); + await page.getByLabel('Edition').click(); + + await expect( + page.getByText('The document visibility has been updated.').first(), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + const otherBrowser = browsersName.find((b) => b !== browserName); + await keyCloakSignIn(page, otherBrowser!); + + await expect( + page.getByRole('link', { name: 'Docs Logo Docs' }), + ).toBeVisible(); + + await page.goto(urlDoc); + + await verifyDocName(page, docTitle); + await page.getByRole('button', { name: 'Share' }).click(); + await page.getByRole('button', { name: 'Copy link' }).click(); + await expect(page.getByText('Link Copied !')).toBeVisible(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/footer.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/footer.spec.ts new file mode 100644 index 00000000..221fbf82 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/footer.spec.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; + +import { overrideConfig } from './common'; + +test.describe('Footer', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('checks the footer is not displayed if no config', async ({ page }) => { + await overrideConfig(page, { + theme_customization: {}, + }); + + await page.goto('/'); + await expect(page.locator('footer')).toBeHidden(); + }); + + test('checks all the elements are visible', async ({ page }) => { + await page.goto('/'); + const footer = page.locator('footer').first(); + + await expect(footer.getByAltText('Docs Logo')).toBeVisible(); + await expect(footer.getByRole('heading', { name: 'Docs' })).toBeVisible(); + + await expect(footer.getByRole('link', { name: 'Github' })).toBeVisible(); + await expect(footer.getByRole('link', { name: 'DINUM' })).toBeVisible(); + await expect(footer.getByRole('link', { name: 'ZenDiS' })).toBeVisible(); + + await expect( + footer.getByRole('link', { name: 'BlockNote.js' }), + ).toBeVisible(); + await expect( + footer.getByRole('link', { name: 'Legal Notice' }), + ).toBeVisible(); + await expect( + footer.getByRole('link', { name: 'Personal data and cookies' }), + ).toBeVisible(); + await expect( + footer.getByRole('link', { name: 'Accessibility' }), + ).toBeVisible(); + + await expect( + footer.getByText( + 'Unless otherwise stated, all content on this site is under licence', + ), + ).toBeVisible(); + + // Check the translation + const header = page.locator('header').first(); + await header.getByRole('button').getByText('English').click(); + await page.getByLabel('Français').click(); + + await expect( + page.locator('footer').getByText('Mentions légales'), + ).toBeVisible(); + }); + + test('checks the footer is correctly overrided', async ({ page }) => { + await overrideConfig(page, { + FRONTEND_THEME: 'dsfr', + theme_customization: { + footer: { + default: { + logo: { + src: '/assets/logo-gouv.svg', + width: '220px', + alt: 'Gouvernement Logo', + }, + externalLinks: [ + { + label: 'legifrance.gouv.fr', + href: '#', + }, + { + label: 'info.gouv.fr', + href: '#', + }, + ], + legalLinks: [ + { + label: 'Legal link', + href: '#', + }, + ], + bottomInformation: { + label: 'Some bottom information text', + link: { + label: 'a custom label', + href: '#', + }, + }, + }, + fr: { + bottomInformation: { + label: "Text d'information en bas de page en français", + link: { + label: 'un label personnalisé', + href: '#', + }, + }, + }, + }, + }, + }); + + await page.goto('/'); + const footer = page.locator('footer').first(); + + await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible(); + + await expect(footer.getByRole('heading', { name: 'Docs' })).toBeHidden(); + await expect(footer.getByText('BETA')).toBeHidden(); + + await expect( + footer.getByRole('link', { name: 'legifrance.gouv.fr' }), + ).toBeVisible(); + + await expect( + footer.getByRole('link', { name: 'info.gouv.fr' }), + ).toBeVisible(); + + await expect( + footer.getByRole('link', { name: 'Legal link' }), + ).toBeVisible(); + + await expect( + footer.getByText('Some bottom information text'), + ).toBeVisible(); + + await expect( + footer.getByRole('link', { name: 'a custom label' }), + ).toBeVisible(); + + // Check the translation + const header = page.locator('header').first(); + await header.getByRole('button').getByText('English').click(); + await page.getByLabel('Français').click(); + + await expect( + page + .locator('footer') + .getByText("Text d'information en bas de page en français"), + ).toBeVisible(); + + await expect( + footer.getByRole('link', { name: 'un label personnalisé' }), + ).toBeVisible(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts new file mode 100644 index 00000000..1abca24b --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts @@ -0,0 +1,130 @@ +import { expect, test } from '@playwright/test'; + +import { expectLoginPage, keyCloakSignIn, overrideConfig } from './common'; + +test.describe('Header', () => { + test('checks all the elements are visible', async ({ page }) => { + await page.goto('/'); + + const header = page.locator('header').first(); + + await expect(header.getByLabel('Docs Logo')).toBeVisible(); + await expect(header.locator('h2').getByText('Docs')).toHaveCSS( + 'font-family', + /Roboto/i, + ); + + await expect( + header.getByRole('button', { + name: 'Logout', + }), + ).toBeVisible(); + + await expect(header.getByText('English')).toBeVisible(); + }); + + test('checks all the elements are visible with DSFR theme', async ({ + page, + }) => { + await overrideConfig(page, { + FRONTEND_THEME: 'dsfr', + }); + await page.goto('/'); + + const header = page.locator('header').first(); + + await expect(header.getByLabel('Docs Logo')).toBeVisible(); + await expect(header.locator('h2').getByText('Docs')).toHaveCSS( + 'font-family', + /Marianne/i, + ); + + await expect( + header.getByRole('button', { + name: 'Logout', + }), + ).toBeVisible(); + + await expect(header.getByText('English')).toBeVisible(); + + await expect( + header.getByRole('button', { + name: 'Les services de La Suite numérique', + }), + ).toBeVisible(); + }); + + test('checks La Gauffre interaction', async ({ page }) => { + await overrideConfig(page, { + FRONTEND_THEME: 'dsfr', + }); + await page.goto('/'); + + const header = page.locator('header').first(); + + await expect( + header.getByRole('button', { + name: 'Les services de La Suite numérique', + }), + ).toBeVisible(); + + /** + * La gaufre load a js file from a remote server, + * it takes some time to load the file and have the interaction available + */ + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1500); + + await header + .getByRole('button', { + name: 'Les services de La Suite numérique', + }) + .click(); + + await expect( + page.getByRole('link', { name: 'France Transfert' }), + ).toBeVisible(); + + await expect(page.getByRole('link', { name: 'Grist' })).toBeVisible(); + }); +}); + +test.describe('Header mobile', () => { + test.use({ viewport: { width: 500, height: 1200 } }); + + test('it checks the header when mobile with DSFR theme', async ({ page }) => { + await overrideConfig(page, { + FRONTEND_THEME: 'dsfr', + }); + + await page.goto('/'); + + const header = page.locator('header').first(); + + await expect(header.getByLabel('Open the header menu')).toBeVisible(); + await expect(header.getByRole('link', { name: 'Docs Logo' })).toBeVisible(); + await expect( + header.getByRole('button', { + name: 'Les services de La Suite numérique', + }), + ).toBeVisible(); + }); +}); + +test.describe('Header: Log out', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + // eslint-disable-next-line playwright/expect-expect + test('checks logout button', async ({ page, browserName }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts new file mode 100644 index 00000000..63b74fae --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test'; + +import { overrideConfig } from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/docs/'); +}); + +test.describe('Home page', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + test('checks all the elements are visible', async ({ page }) => { + await page.goto('/docs/'); + + // Check header content + const header = page.locator('header').first(); + const footer = page.locator('footer').first(); + await expect(header).toBeVisible(); + await expect( + header.getByRole('button', { name: /Language/ }), + ).toBeVisible(); + await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible(); + await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible(); + + // Check the titles + const h2 = page.locator('h2'); + await expect(h2.getByText('Govs ❤️ Open Source.')).toBeVisible(); + await expect( + h2.getByText('Collaborative writing, Simplified.'), + ).toBeVisible(); + await expect( + h2.getByText('An uncompromising writing experience.'), + ).toBeVisible(); + await expect( + h2.getByText('Simple and secure collaboration.'), + ).toBeVisible(); + await expect(h2.getByText('Flexible export.')).toBeVisible(); + await expect( + h2.getByText('A new way to organize knowledge.'), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Start Writing' }), + ).toBeVisible(); + + await expect(footer).toBeVisible(); + }); + + test('checks all the elements are visible with dsfr theme', async ({ + page, + }) => { + await overrideConfig(page, { + FRONTEND_THEME: 'dsfr', + theme_customization: { + footer: { + default: { + externalLinks: [ + { + label: 'legifrance.gouv.fr', + href: '#', + }, + ], + }, + }, + }, + }); + + await page.goto('/docs/'); + + // Check header content + const header = page.locator('header').first(); + const footer = page.locator('footer').first(); + await expect(header).toBeVisible(); + await expect( + header.getByRole('button', { name: /Language/ }), + ).toBeVisible(); + await expect( + header.getByRole('button', { name: 'Les services de La Suite numé' }), + ).toBeVisible(); + await expect( + header.getByRole('img', { name: 'Gouvernement Logo' }), + ).toBeVisible(); + await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible(); + await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible(); + await expect(header.getByText('BETA')).toBeVisible(); + + // Check the titles + const h2 = page.locator('h2'); + await expect(h2.getByText('Govs ❤️ Open Source.')).toBeVisible(); + await expect( + h2.getByText('Collaborative writing, Simplified.'), + ).toBeVisible(); + await expect( + h2.getByText('An uncompromising writing experience.'), + ).toBeVisible(); + await expect( + h2.getByText('Simple and secure collaboration.'), + ).toBeVisible(); + await expect(h2.getByText('Flexible export.')).toBeVisible(); + await expect( + h2.getByText('A new way to organize knowledge.'), + ).toBeVisible(); + + await expect( + page.getByText('Docs is already available, log in to use it now.'), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Proconnect Login' }), + ).toHaveCount(2); + + await expect(footer).toBeVisible(); + }); + + test('it checks the homepage feature flag', async ({ page }) => { + await overrideConfig(page, { + FRONTEND_HOMEPAGE_FEATURE_ENABLED: false, + }); + + await page.goto('/'); + + // Keyclock login page + await expect( + page.locator('.login-pf-page-header').getByText('impress'), + ).toBeVisible(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts new file mode 100644 index 00000000..5805d010 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -0,0 +1,142 @@ +import { Page, expect, test } from '@playwright/test'; + +import { createDoc } from './common'; + +test.describe.serial('Language', () => { + let page: Page; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForLanguageSwitch(page, TestLanguage.English); + }); + + test.afterEach(async ({ page }) => { + // Switch back to English - important for other tests to run as expected + await waitForLanguageSwitch(page, TestLanguage.English); + }); + + test('checks language switching', async ({ page }) => { + const header = page.locator('header').first(); + + // initial language should be english + await expect( + page.getByRole('button', { + name: 'New doc', + }), + ).toBeVisible(); + + // switch to french + await waitForLanguageSwitch(page, TestLanguage.French); + + await expect( + header.getByRole('button').getByText('Français'), + ).toBeVisible(); + + await expect(page.getByLabel('Se déconnecter')).toBeVisible(); + + await header.getByRole('button').getByText('Français').click(); + await page.getByLabel('Deutsch').click(); + await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible(); + + await expect(page.getByLabel('Abmelden')).toBeVisible(); + }); + + test('checks that backend uses the same language as the frontend', async ({ + page, + }) => { + // Helper function to intercept and assert 404 response + const check404Response = async (expectedDetail: string) => { + const interceptedBackendResponse = await page.request.get( + 'http://localhost:8071/api/v1.0/documents/non-existent-doc-uuid/', + ); + + // Assert that the intercepted error message is in the expected language + expect(await interceptedBackendResponse.json()).toStrictEqual({ + detail: expectedDetail, + }); + }; + + // Check for English 404 response + await check404Response('Not found.'); + + await waitForLanguageSwitch(page, TestLanguage.French); + + // Check for French 404 response + await check404Response('Pas trouvé.'); + }); + + test('it check translations of the slash menu when changing language', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-toolbar', browserName, 1); + + const header = page.locator('header').first(); + const editor = page.locator('.ProseMirror'); + // Trigger slash menu to show english menu + await editor.click(); + await editor.fill('/'); + await expect(page.getByText('Headings', { exact: true })).toBeVisible(); + await header.click(); + await expect(page.getByText('Headings', { exact: true })).toBeHidden(); + + // Reset menu + await editor.click(); + await editor.fill(''); + + // Change language to French + await waitForLanguageSwitch(page, TestLanguage.French); + + // Trigger slash menu to show french menu + await editor.click(); + await editor.fill('/'); + await expect(page.getByText('Titres', { exact: true })).toBeVisible(); + await header.click(); + await expect(page.getByText('Titres', { exact: true })).toBeHidden(); + }); +}); + +// language helper +export const TestLanguage = { + English: { + label: 'English', + expectedLocale: ['en-us'], + }, + French: { + label: 'Français', + expectedLocale: ['fr-fr'], + }, + German: { + label: 'Deutsch', + expectedLocale: ['de-de'], + }, +} as const; + +type TestLanguageKey = keyof typeof TestLanguage; +type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey]; + +export async function waitForLanguageSwitch( + page: Page, + lang: TestLanguageValue, +) { + const header = page.locator('header').first(); + await header.getByRole('button', { name: 'arrow_drop_down' }).click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes('/user') && resp.request().method() === 'PATCH', + ); + await page.getByLabel(lang.label).click(); + const resolvedResponsePromise = await responsePromise; + const responseData = await resolvedResponsePromise.json(); + + expect(lang.expectedLocale).toContain(responseData.language); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts new file mode 100644 index 00000000..97523aa5 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Left panel desktop', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('checks all the elements are visible', async ({ page }) => { + await expect(page.getByTestId('left-panel-desktop')).toBeVisible(); + await expect(page.getByTestId('left-panel-mobile')).toBeHidden(); + await expect(page.getByRole('button', { name: 'house' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible(); + }); +}); + +test.describe('Left panel mobile', () => { + test.use({ viewport: { width: 500, height: 1200 } }); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('checks all the desktop elements are hidden and all mobile elements are visible', async ({ + page, + }) => { + await expect(page.getByTestId('left-panel-desktop')).toBeHidden(); + await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport(); + + const header = page.locator('header').first(); + const homeButton = page.getByRole('button', { name: 'house' }); + const newDocButton = page.getByRole('button', { name: 'New doc' }); + const languageButton = page.getByRole('button', { name: /Language/ }); + const logoutButton = page.getByRole('button', { name: 'Logout' }); + + await expect(homeButton).not.toBeInViewport(); + await expect(newDocButton).not.toBeInViewport(); + await expect(languageButton).not.toBeInViewport(); + await expect(logoutButton).not.toBeInViewport(); + + await header.getByLabel('Open the header menu').click(); + + await expect(page.getByTestId('left-panel-mobile')).toBeInViewport(); + await expect(homeButton).toBeInViewport(); + await expect(newDocButton).toBeInViewport(); + await expect(languageButton).toBeInViewport(); + await expect(logoutButton).toBeInViewport(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/helpers.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/helpers.ts new file mode 100644 index 00000000..678b555a --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/__tests__/helpers.ts @@ -0,0 +1,21 @@ +import { Locator } from '@playwright/test'; + +export async function waitForElementCount( + locator: Locator, + count: number, + timeout: number, +) { + let elapsedTime = 0; + const interval = 200; // Check every 200 ms + while (elapsedTime < timeout) { + const currentCount = await locator.count(); + if (currentCount >= count) { + return true; + } + await locator.page().waitForTimeout(interval); // Wait for the interval before checking again + elapsedTime += interval; + } + throw new Error( + `Timeout after ${timeout}ms waiting for element count to be at least ${count}`, + ); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/package.json b/submissions/devoteam/docs/src/frontend/apps/e2e/package.json new file mode 100644 index 00000000..93d0094d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/package.json @@ -0,0 +1,25 @@ +{ + "name": "app-e2e", + "version": "3.3.0", + "private": true, + "scripts": { + "lint": "eslint . --ext .ts", + "install-playwright": "playwright install --with-deps", + "test": "playwright test", + "test:ui": "yarn test --ui", + "test:ui::firefox": "yarn test:ui --project=firefox", + "test:ui::webkit": "yarn test:ui --project=webkit", + "test:ui::chromium": "yarn test:ui --project=chromium" + }, + "devDependencies": { + "@playwright/test": "1.52.0", + "@types/node": "*", + "@types/pdf-parse": "1.1.5", + "eslint-config-impress": "*", + "typescript": "*" + }, + "dependencies": { + "convert-stream": "1.0.2", + "pdf-parse": "1.1.1" + } +} diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/playwright.config.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/playwright.config.ts new file mode 100644 index 00000000..cdb6aadc --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/playwright.config.ts @@ -0,0 +1,81 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = process.env.PORT || 3000; + +const baseURL = `http://localhost:${PORT}`; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // Timeout per test + timeout: 30 * 1000, + testDir: './__tests__', + outputDir: './test-results', + + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + maxFailures: process.env.CI ? 3 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 3 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [['html', { outputFolder: './report' }]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + webServer: { + command: !process.env.CI ? `cd ../.. && yarn app:dev --port ${PORT}` : '', + url: baseURL, + timeout: 120 * 1000, + reuseExistingServer: true, + }, + globalSetup: require.resolve('./__tests__/app-impress/auth.setup'), + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + locale: 'en-US', + timezoneId: 'Europe/Paris', + storageState: 'playwright/.auth/user-chromium.json', + contextOptions: { + permissions: ['clipboard-read', 'clipboard-write'], + }, + }, + }, + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + locale: 'en-US', + timezoneId: 'Europe/Paris', + storageState: 'playwright/.auth/user-webkit.json', + }, + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + locale: 'en-US', + timezoneId: 'Europe/Paris', + storageState: 'playwright/.auth/user-firefox.json', + launchOptions: { + firefoxUserPrefs: { + 'dom.events.asyncClipboard.readText': true, + 'dom.events.testing.asyncClipboard': true, + }, + }, + }, + }, + ], +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/tsconfig.json b/submissions/devoteam/docs/src/frontend/apps/e2e/tsconfig.json new file mode 100644 index 00000000..a9aa4817 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + }, + "include": ["**/*.ts", "**/*.d.ts"], + "exclude": ["node_modules"] +} diff --git a/submissions/devoteam/docs/src/frontend/apps/e2e/type/convert-stream.d.ts b/submissions/devoteam/docs/src/frontend/apps/e2e/type/convert-stream.d.ts new file mode 100644 index 00000000..d79cbccc --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/e2e/type/convert-stream.d.ts @@ -0,0 +1,5 @@ +declare module 'convert-stream' { + export function toBuffer( + readableStream: NodeJS.ReadableStream, + ): Promise; +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/.env b/submissions/devoteam/docs/src/frontend/apps/impress/.env new file mode 100644 index 00000000..bcf7592f --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/.env @@ -0,0 +1,3 @@ +NEXT_PUBLIC_API_ORIGIN= +NEXT_PUBLIC_SW_DEACTIVATED= +NEXT_PUBLIC_PUBLISH_AS_MIT=true diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/.env.development b/submissions/devoteam/docs/src/frontend/apps/impress/.env.development new file mode 100644 index 00000000..248c7265 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/.env.development @@ -0,0 +1,3 @@ +NEXT_PUBLIC_API_ORIGIN=http://localhost:8071 +NEXT_PUBLIC_PUBLISH_AS_MIT=false +NEXT_PUBLIC_SW_DEACTIVATED=true diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/.env.test b/submissions/devoteam/docs/src/frontend/apps/impress/.env.test new file mode 100644 index 00000000..9a4d5142 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/.env.test @@ -0,0 +1 @@ +NEXT_PUBLIC_API_ORIGIN=http://test.jest diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/.eslintrc.js b/submissions/devoteam/docs/src/frontend/apps/impress/.eslintrc.js new file mode 100644 index 00000000..f2dbec76 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + root: true, + extends: ['impress/next'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + settings: { + next: { + rootDir: __dirname, + }, + }, + ignorePatterns: ['node_modules', '.eslintrc.js', 'service-worker.js'], +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/.gitignore b/submissions/devoteam/docs/src/frontend/apps/impress/.gitignore new file mode 100644 index 00000000..0abcbb52 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo + +service-worker.js diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/.prettierignore b/submissions/devoteam/docs/src/frontend/apps/impress/.prettierignore new file mode 100644 index 00000000..15b20443 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/.prettierignore @@ -0,0 +1,2 @@ +next-env.d.ts +service-worker.js diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/README.md b/submissions/devoteam/docs/src/frontend/apps/impress/README.md new file mode 100644 index 00000000..c4033664 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/conf/default.conf b/submissions/devoteam/docs/src/frontend/apps/impress/conf/default.conf new file mode 100644 index 00000000..5fe19bc4 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/conf/default.conf @@ -0,0 +1,20 @@ +server { + listen 8080; + listen 3000; + server_name localhost; + + root /usr/share/nginx/html; + + location / { + try_files $uri index.html $uri/ =404; + } + + location ~ "^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$" { + try_files $uri /docs/[id]/index.html; + } + + error_page 404 /404.html; + location = /404.html { + internal; + } +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/cunningham.ts b/submissions/devoteam/docs/src/frontend/apps/impress/cunningham.ts new file mode 100644 index 00000000..1b6481ab --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/cunningham.ts @@ -0,0 +1,256 @@ +import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit'; + +const customColors = { + 'primary-action': '#1212FF', + 'primary-bg': '#FAFAFA', + 'primary-focus': '#0A76F6', + 'secondary-icon': 'var(--c--theme--colors--primary-text)', + 'blue-400': '#7AB1E8', + 'blue-500': '#417DC4', + 'blue-600': '#3558A2', + 'brown-400': '#E6BE92', + 'brown-500': '#BD987A', + 'brown-600': '#745B47', + 'cyan-400': '#34BAB5', + 'cyan-500': '#009099', + 'cyan-600': '#006A6F', + 'gold-400': '#FFCA00', + 'gold-500': '#C3992A', + 'gold-600': '#695240', + 'green-400': '#34CB6A', + 'green-500': '#00A95F', + 'green-600': '#297254', + 'olive-400': '#99C221', + 'olive-500': '#68A532', + 'olive-600': '#447049', + 'orange-400': '#FF732C', + 'orange-500': '#E4794A', + 'orange-600': '#755348', + 'pink-400': '#FFB7AE', + 'pink-500': '#E18B76', + 'pink-600': '#8D533E', + 'purple-400': '#CE70CC', + 'purple-500': '#A558A0', + 'purple-600': '#6E445A', + 'yellow-400': '#D8C634', + 'yellow-500': '#B7A73F', + 'yellow-600': '#66673D', +}; + +tokens.themes.default.theme = { + ...tokens.themes.default.theme, + ...{ + logo: { + src: '', + alt: '', + widthHeader: '', + widthFooter: '', + }, + colors: { + ...tokens.themes.default.theme.colors, + ...customColors, + }, + }, +}; + +tokens.themes.default.components = { + ...tokens.themes.default.components, + ...{ + 'la-gaufre': false, + 'home-proconnect': false, + beta: false, + 'image-system-filter': '', + favicon: { + ico: '/assets/favicon-light.ico', + 'png-light': '/assets/favicon-light.png', + 'png-dark': '/assets/favicon-dark.png', + }, + }, +}; + +const dsfrTheme = { + dsfr: { + theme: { + colors: { + 'secondary-icon': '#C9191E', + }, + logo: { + src: '/assets/logo-gouv.svg', + widthHeader: '110px', + widthFooter: '220px', + alt: 'Gouvernement Logo', + }, + }, + components: { + 'la-gaufre': true, + 'home-proconnect': true, + beta: true, + favicon: { + ico: '/assets/favicon-dsfr.ico', + 'png-light': '/assets/favicon-dsfr.png', + 'png-dark': '/assets/favicon-dark-dsfr.png', + }, + }, + }, +}; + +const genericTheme = { + generic: { + theme: { + colors: { + 'primary-action': '#206EBD', + 'primary-focus': '#1E64BF', + 'primary-text': '#2E2C28', + 'primary-050': '#F8F8F7', + 'primary-100': '#F0EFEC', + 'primary-150': '#F4F4FD', + 'primary-200': '#E8E7E4', + 'primary-300': '#CFCDC9', + 'primary-400': '#979592', + 'primary-500': '#82807D', + 'primary-600': '#3F3D39', + 'primary-700': '#2E2C28', + 'primary-800': '#302E29', + 'primary-900': '#282622', + 'primary-950': '#201F1C', + 'secondary-text': '#fff', + 'secondary-50': '#F4F7FA', + 'secondary-100': '#D7E3EE', + 'secondary-200': '#B8CCE1', + 'secondary-300': '#99B4D3', + 'secondary-400': '#7595BE', + 'secondary-500': '#5874A0', + 'secondary-600': '#3A5383', + 'secondary-700': '#1E3462', + 'secondary-800': '#091B41', + 'secondary-900': '#08183B', + 'secondary-950': '#071636', + 'greyscale-text': '#3C3B38', + 'greyscale-000': '#fff', + 'greyscale-050': '#F8F7F7', + 'greyscale-100': '#F3F3F2', + 'greyscale-200': '#ECEBEA', + 'greyscale-250': '#E4E3E2', + 'greyscale-300': '#D3D2CF', + 'greyscale-350': '#eee', + 'greyscale-400': '#96948E', + 'greyscale-500': '#817E77', + 'greyscale-600': '#6A6862', + 'greyscale-700': '#3C3B38', + 'greyscale-750': '#383632', + 'greyscale-800': '#2D2B27', + 'greyscale-900': '#262522', + 'greyscale-950': '#201F1C', + 'greyscale-1000': '#181714', + 'success-text': '#234935', + 'success-50': '#F3FBF5', + 'success-100': '#E4F7EA', + 'success-200': '#CAEED4', + 'success-300': '#A0E0B5', + 'success-400': '#6CC88C', + 'success-500': '#6CC88C', + 'success-600': '#358D5C', + 'success-700': '#2D704B', + 'success-800': '#28583F', + 'success-900': '#234935', + 'success-950': '#0F281B', + 'info-text': '#212445', + 'info-50': '#F2F6FB', + 'info-100': '#E2E9F5', + 'info-200': '#CCD8EE', + 'info-300': '#A9C0E3', + 'info-400': '#809DD4', + 'info-500': '#617BC7', + 'info-600': '#4A5CBF', + 'info-700': '#3E49B2', + 'info-800': '#353C8F', + 'info-900': '#303771', + 'info-950': '#212445', + 'warning-text': '#D97C3A', + 'warning-50': '#FDF7F1', + 'warning-100': '#FBEDDC', + 'warning-200': '#F5D9B9', + 'warning-300': '#EDBE8C', + 'warning-400': '#E2985C', + 'warning-500': '#D97C3A', + 'warning-600': '#C96330', + 'warning-700': '#A34B32', + 'warning-800': '#813B2C', + 'warning-900': '#693327', + 'warning-950': '#381713', + 'danger-action': '#C0182A', + 'danger-text': '#FFF', + 'danger-050': '#FDF5F4', + 'danger-100': '#FBEBE8', + 'danger-200': '#F9E0DC', + 'danger-300': '#F3C3BD', + 'danger-400': '#E26552', + 'danger-500': '#C91F00', + 'danger-600': '#A71901', + 'danger-700': '#562C2B', + 'danger-800': '#392425', + 'danger-900': '#311F20', + 'danger-950': '#2A191A', + 'blue-400': '#8BAECC', + 'blue-500': '#567AA2', + 'blue-600': '#455784', + 'brown-400': '#E4C090', + 'brown-500': '#BA9977', + 'brown-600': '#735C45', + 'cyan-400': '#5CBEC9', + 'cyan-500': '#43A1B3', + 'cyan-600': '#39809B', + 'gold-400': '#ECBF50', + 'gold-500': '#DFA038', + 'gold-600': '#C17B31', + 'green-400': '#5DBD9A', + 'green-500': '#3AA183', + 'green-600': '#2A816D', + 'olive-400': '#AFD662', + 'olive-500': '#90BB4B', + 'olive-600': '#6E9441', + 'orange-400': '#E2985C', + 'orange-500': '#D97C3A', + 'orange-600': '#C96330', + 'pink-400': '#BE8FC8', + 'pink-500': '#A563B1', + 'pink-600': '#8B44A5', + 'purple-400': '#BE8FC8', + 'purple-500': '#A563B1', + 'purple-600': '#8B44A5', + 'yellow-400': '#EDC947', + 'yellow-500': '#DBB13A', + 'yellow-600': '#B88A34', + }, + font: { + families: { + base: 'Inter, Roboto Flex Variable, sans-serif', + accent: 'Inter, Roboto Flex Variable, sans-serif', + }, + }, + }, + components: { + button: { + primary: { + background: { + 'color-hover': 'var(--c--theme--colors--primary-focus)', + 'color-active': 'var(--c--theme--colors--primary-focus)', + 'color-focus': 'var(--c--theme--colors--primary-focus)', + }, + }, + }, + 'image-system-filter': 'saturate(0.2)', + }, + }, +}; + +const docsTokens = { + ...tokens, + themes: { + ...tokens.themes, + ...dsfrTheme, + ...genericTheme, + }, +}; + +export default docsTokens; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/jest.config.ts b/submissions/devoteam/docs/src/frontend/apps/impress/jest.config.ts new file mode 100644 index 00000000..05bfb0f3 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/jest.config.ts @@ -0,0 +1,33 @@ +import type { Config } from 'jest'; +import nextJest from 'next/jest.js'; + +const createJestConfig = nextJest({ + dir: './', +}); + +// Add any custom config to be passed to Jest +const config: Config = { + coverageProvider: 'v8', + moduleNameMapper: { + '^@/docs/(.*)$': '/src/features/docs/$1', + '^@/(.*)$': '/src/$1', + }, + setupFilesAfterEnv: ['/jest.setup.ts'], + testEnvironment: 'jsdom', +}; + +const jestConfig = async () => { + const nextJestConfig = await createJestConfig(config)(); + return { + ...nextJestConfig, + moduleNameMapper: { + '\\.svg$': '/jest/mocks/svg.js', + '^.+\\.svg\\?url$': `/jest/mocks/fileMock.js`, + BlockNoteEditor: `/jest/mocks/ComponentMock.js`, + 'custom-blocks': `/jest/mocks/ComponentMock.js`, + ...nextJestConfig.moduleNameMapper, + }, + }; +}; + +export default jestConfig; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/jest.setup.ts b/submissions/devoteam/docs/src/frontend/apps/impress/jest.setup.ts new file mode 100644 index 00000000..564d8a6d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/jest.setup.ts @@ -0,0 +1,4 @@ +import '@testing-library/jest-dom'; +import * as dotenv from 'dotenv'; + +dotenv.config({ path: './.env.test' }); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/ComponentMock.js b/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/ComponentMock.js new file mode 100644 index 00000000..812a08b1 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/ComponentMock.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export const ComponentMock = () => { + return
My component mocked
; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/fileMock.js b/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/fileMock.js new file mode 100644 index 00000000..28a24984 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/fileMock.js @@ -0,0 +1,16 @@ +module.exports = { + src: '/img.jpg', + height: 40, + width: 40, + blurDataURL: 'data:image/png;base64,imagedata', +}; + +if ( + (typeof exports.default === 'function' || + (typeof exports.default === 'object' && exports.default !== null)) && + typeof exports.default.__esModule === 'undefined' +) { + Object.defineProperty(exports.default, '__esModule', { value: true }); + Object.assign(exports.default, exports); + module.exports = exports.default; +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/svg.js b/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/svg.js new file mode 100644 index 00000000..0b7fc5b8 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/jest/mocks/svg.js @@ -0,0 +1,3 @@ +const nameMock = 'svg'; +export default nameMock; +export const ReactComponent = 'svg'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/next-env.d.ts b/submissions/devoteam/docs/src/frontend/apps/impress/next-env.d.ts new file mode 100644 index 00000000..52e831b4 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/next.config.js b/submissions/devoteam/docs/src/frontend/apps/impress/next.config.js new file mode 100644 index 00000000..862b4faf --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/next.config.js @@ -0,0 +1,65 @@ +const crypto = require('crypto'); + +const { InjectManifest } = require('workbox-webpack-plugin'); + +const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'export', + trailingSlash: true, + images: { + unoptimized: true, + }, + compiler: { + // Enables the styled-components SWC transform + styledComponents: true, + }, + generateBuildId: () => buildId, + env: { + NEXT_PUBLIC_BUILD_ID: buildId, + }, + webpack(config, { isServer }) { + // Grab the existing rule that handles SVG imports + const fileLoaderRule = config.module.rules.find((rule) => + rule.test?.test?.('.svg'), + ); + + config.module.rules.push( + // Reapply the existing rule, but only for svg imports ending in ?url + { + ...fileLoaderRule, + test: /\.svg$/i, + resourceQuery: /url/, // *.svg?url + }, + // Convert all other *.svg imports to React components + { + test: /\.svg$/i, + issuer: fileLoaderRule.issuer, + resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url + use: ['@svgr/webpack'], + }, + ); + + if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') { + config.plugins.push( + new InjectManifest({ + swSrc: './src/features/service-worker/service-worker.ts', + swDest: '../public/service-worker.js', + include: [ + ({ asset }) => { + return !!asset.name.match(/.*(static).*/); + }, + ], + }), + ); + } + + // Modify the file loader rule to ignore *.svg, since we have it handled now. + fileLoaderRule.exclude = /\.svg$/i; + + return config; + }, +}; + +module.exports = nextConfig; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/package.json b/submissions/devoteam/docs/src/frontend/apps/impress/package.json new file mode 100644 index 00000000..c111d1a4 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/package.json @@ -0,0 +1,88 @@ +{ + "name": "app-impress", + "version": "3.3.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "prettier --check . && yarn stylelint && next build", + "build:ci": "cp .env.development .env.local && yarn build", + "build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix", + "start": "npx -y serve@latest out", + "lint": "tsc --noEmit && next lint", + "prettier": "prettier --write .", + "stylelint": "stylelint \"**/*.css\"", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@ag-media/react-pdf-table": "2.0.3", + "@blocknote/code-block": "0.31.1", + "@blocknote/core": "0.31.1", + "@blocknote/mantine": "0.31.1", + "@blocknote/react": "0.31.1", + "@blocknote/xl-docx-exporter": "0.31.1", + "@blocknote/xl-pdf-exporter": "0.31.1", + "@emoji-mart/data": "1.2.1", + "@emoji-mart/react": "1.1.1", + "@fontsource/material-icons": "5.2.5", + "@gouvfr-lasuite/integration": "1.0.3", + "@gouvfr-lasuite/ui-kit": "0.7.0", + "@hocuspocus/provider": "2.15.2", + "@openfun/cunningham-react": "3.1.0", + "@react-pdf/renderer": "4.3.0", + "@sentry/nextjs": "9.22.0", + "@tanstack/react-query": "5.77.1", + "canvg": "4.0.3", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "crisp-sdk-web": "1.0.25", + "docx": "9.5.0", + "emoji-mart": "5.6.0", + "i18next": "25.2.1", + "i18next-browser-languagedetector": "8.1.0", + "idb": "8.0.3", + "lodash": "4.17.21", + "luxon": "3.6.1", + "next": "15.3.2", + "posthog-js": "1.246.0", + "react": "*", + "react-aria-components": "1.9.0", + "react-dom": "*", + "react-i18next": "15.5.2", + "react-intersection-observer": "9.16.0", + "react-select": "5.10.1", + "styled-components": "6.1.18", + "use-debounce": "10.0.4", + "y-protocols": "1.0.6", + "yjs": "*", + "zustand": "5.0.5" + }, + "devDependencies": { + "@svgr/webpack": "8.1.0", + "@tanstack/react-query-devtools": "5.77.1", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", + "@types/jest": "29.5.14", + "@types/lodash": "4.17.17", + "@types/luxon": "3.6.2", + "@types/node": "*", + "@types/react": "*", + "@types/react-dom": "*", + "cross-env": "7.0.3", + "dotenv": "16.5.0", + "eslint-config-impress": "*", + "fetch-mock": "9.11.0", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "node-fetch": "2.7.0", + "prettier": "3.5.3", + "stylelint": "16.19.1", + "stylelint-config-standard": "38.0.0", + "stylelint-prettier": "5.0.3", + "typescript": "*", + "webpack": "5.99.9", + "workbox-webpack-plugin": "7.1.0" + } +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/SC1-en.webm b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/SC1-en.webm new file mode 100644 index 00000000..ff1dc284 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/SC1-en.webm differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/SC1-fr.webm b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/SC1-fr.webm new file mode 100644 index 00000000..1ebc7bef Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/SC1-fr.webm differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dark-dsfr.png b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dark-dsfr.png new file mode 100644 index 00000000..73603e67 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dark-dsfr.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dark.png b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dark.png new file mode 100644 index 00000000..1d05fec8 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dark.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dsfr.ico b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dsfr.ico new file mode 100644 index 00000000..97630272 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dsfr.ico differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dsfr.png b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dsfr.png new file mode 100644 index 00000000..4c433bb5 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-dsfr.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-light.ico b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-light.ico new file mode 100644 index 00000000..b4255b9f Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-light.ico differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-light.png b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-light.png new file mode 100644 index 00000000..19cc87d7 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/favicon-light.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/icon-docs.svg b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/icon-docs.svg new file mode 100644 index 00000000..05cf0436 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/icon-docs.svg @@ -0,0 +1,12 @@ + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/img-not-found.svg b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/img-not-found.svg new file mode 100644 index 00000000..7039b94b --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/img-not-found.svg @@ -0,0 +1,29 @@ + + + + + + no-image + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-gouv.png b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-gouv.png new file mode 100644 index 00000000..f9a07d8b Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-gouv.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-gouv.svg b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-gouv.svg new file mode 100644 index 00000000..824e1b94 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-gouv.svg @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-pdf.svg b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-pdf.svg new file mode 100644 index 00000000..19cf5153 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-pdf.svg @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-suite-numerique.png b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-suite-numerique.png new file mode 100644 index 00000000..840e972e Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/logo-suite-numerique.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/mail-header-background.png b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/mail-header-background.png new file mode 100644 index 00000000..d922bd38 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/public/assets/mail-header-background.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/APIError.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/APIError.ts new file mode 100644 index 00000000..6d7b92bc --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/APIError.ts @@ -0,0 +1,52 @@ +/** + * Generic interface for representing an API error structure. + * + * @template T - Optional type of additional data returned with the error. + */ +interface IAPIError { + /** HTTP status code or API-defined error code */ + status: number; + /** Optional list of error causes (e.g., validation issues) */ + cause?: string[]; + /** Optional extra data provided with the error */ + data?: T; +} + +/** + * Custom error class for representing API errors. + * Extends the native Error object with additional context such as HTTP status, + * causes, and extra data returned by the API. + * + * @template T - Optional type of the `data` field + */ +export class APIError extends Error implements IAPIError { + public status: IAPIError['status']; + public cause?: IAPIError['cause']; + public data?: IAPIError['data']; + + /** + * Constructs a new APIError instance. + * + * @param message - The human-readable error message. + * @param status - The HTTP status code or equivalent. + * @param cause - (Optional) List of strings describing error causes. + * @param data - (Optional) Any additional data returned by the API. + */ + constructor(message: string, { status, cause, data }: IAPIError) { + super(message); + this.name = 'APIError'; + this.status = status; + this.cause = cause; + this.data = data; + } +} + +/** + * Type guard for checking if a value is an instance of APIError. + * + * @param error - The value to check. + * @returns True if the value is an instance of APIError. + */ +export const isAPIError = (error: unknown): error is APIError => { + return error instanceof APIError; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/APIError.test.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/APIError.test.ts new file mode 100644 index 00000000..395a5460 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/APIError.test.ts @@ -0,0 +1,36 @@ +import { APIError, isAPIError } from '@/api'; + +describe('APIError', () => { + it('should correctly instantiate with required fields', () => { + const error = new APIError('Something went wrong', { status: 500 }); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(APIError); + expect(error.message).toBe('Something went wrong'); + expect(error.status).toBe(500); + expect(error.cause).toBeUndefined(); + expect(error.data).toBeUndefined(); + }); + + it('should correctly instantiate with all fields', () => { + const details = { field: 'email' }; + const error = new APIError('Validation failed', { + status: 400, + cause: ['Invalid email format'], + data: details, + }); + + expect(error.name).toBe('APIError'); + expect(error.status).toBe(400); + expect(error.cause).toEqual(['Invalid email format']); + expect(error.data).toEqual(details); + }); + + it('should be detected by isAPIError type guard', () => { + const error = new APIError('Unauthorized', { status: 401 }); + const notAnError = { message: 'Fake error' }; + + expect(isAPIError(error)).toBe(true); + expect(isAPIError(notAnError)).toBe(false); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/config.test.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/config.test.ts new file mode 100644 index 00000000..cb9bf268 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/config.test.ts @@ -0,0 +1,16 @@ +import { baseApiUrl } from '@/api'; + +describe('config', () => { + it('constructs URL with default version', () => { + expect(baseApiUrl()).toBe('http://test.jest/api/v1.0/'); + }); + + it('constructs URL with custom version', () => { + expect(baseApiUrl('2.0')).toBe('http://test.jest/api/v2.0/'); + }); + + it('uses env origin if available', () => { + process.env.NEXT_PUBLIC_API_ORIGIN = 'https://env.example.com'; + expect(baseApiUrl('3.0')).toBe('https://env.example.com/api/v3.0/'); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/fetchApi.test.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/fetchApi.test.tsx new file mode 100644 index 00000000..9b9d6a9a --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/fetchApi.test.tsx @@ -0,0 +1,48 @@ +import fetchMock from 'fetch-mock'; + +import { fetchAPI } from '@/api'; + +describe('fetchAPI', () => { + beforeEach(() => { + fetchMock.restore(); + }); + + it('adds correctly the basename', () => { + fetchMock.mock('http://test.jest/api/v1.0/some/url', 200); + + void fetchAPI('some/url'); + + expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/v1.0/some/url'); + }); + + it('adds the credentials automatically', () => { + fetchMock.mock('http://test.jest/api/v1.0/some/url', 200); + + void fetchAPI('some/url', { body: 'some body' }); + + expect(fetchMock.lastOptions()).toEqual({ + body: 'some body', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('check the versionning', () => { + fetchMock.mock('http://test.jest/api/v2.0/some/url', 200); + + void fetchAPI('some/url', {}, '2.0'); + + expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/v2.0/some/url'); + }); + + it('removes Content-Type header when withoutContentType is true', async () => { + fetchMock.mock('http://test.jest/api/v1.0/some/url', 200); + + await fetchAPI('some/url', { withoutContentType: true }); + + const options = fetchMock.lastOptions(); + expect(options?.headers).not.toHaveProperty('Content-Type'); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/helpers.test.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/helpers.test.tsx new file mode 100644 index 00000000..e4706367 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/helpers.test.tsx @@ -0,0 +1,59 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useAPIInfiniteQuery } from '@/api'; + +interface DummyItem { + id: number; +} + +interface DummyResponse { + results: DummyItem[]; + next?: string; +} + +const createWrapper = () => { + const queryClient = new QueryClient(); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe('helpers', () => { + it('fetches and paginates correctly', async () => { + const mockAPI = jest + .fn, [{ page: number; query: string }]>() + .mockResolvedValueOnce({ + results: [{ id: 1 }], + next: 'url?page=2', + }) + .mockResolvedValueOnce({ + results: [{ id: 2 }], + next: undefined, + }); + + const { result } = renderHook( + () => useAPIInfiniteQuery('test-key', mockAPI, { query: 'test' }), + { wrapper: createWrapper() }, + ); + + // Wait for first page + await waitFor(() => { + expect(result.current.data?.pages[0].results[0].id).toBe(1); + }); + + // Fetch next page + await result.current.fetchNextPage(); + + await waitFor(() => { + expect(result.current.data?.pages.length).toBe(2); + }); + + await waitFor(() => { + expect(result.current.data?.pages[1].results[0].id).toBe(2); + }); + + expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 1 }); + expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 2 }); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/utils.test.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/utils.test.ts new file mode 100644 index 00000000..86433188 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/__tests__/utils.test.ts @@ -0,0 +1,57 @@ +import { errorCauses, getCSRFToken } from '@/api'; + +describe('utils', () => { + describe('errorCauses', () => { + const createMockResponse = (jsonData: any, status = 400): Response => { + return { + status, + json: () => jsonData, + } as unknown as Response; + }; + + it('parses multiple string causes from error body', async () => { + const mockResponse = createMockResponse( + { + field: ['error message 1', 'error message 2'], + }, + 400, + ); + + const result = await errorCauses(mockResponse, { context: 'login' }); + + expect(result.status).toBe(400); + expect(result.cause).toEqual(['error message 1', 'error message 2']); + expect(result.data).toEqual({ context: 'login' }); + }); + + it('returns undefined causes if no JSON body', async () => { + const mockResponse = createMockResponse(null, 500); + + const result = await errorCauses(mockResponse); + + expect(result.status).toBe(500); + expect(result.cause).toBeUndefined(); + expect(result.data).toBeUndefined(); + }); + }); + + describe('getCSRFToken', () => { + it('extracts csrftoken from document.cookie', () => { + Object.defineProperty(document, 'cookie', { + writable: true, + value: 'sessionid=xyz; csrftoken=abc123; theme=dark', + }); + + expect(getCSRFToken()).toBe('abc123'); + }); + + it('returns undefined if csrftoken is not present', () => { + Object.defineProperty(document, 'cookie', { + writable: true, + value: 'sessionid=xyz; theme=dark', + }); + + expect(getCSRFToken()).toBeUndefined(); + }); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/config.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/config.ts new file mode 100644 index 00000000..916585e6 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/config.ts @@ -0,0 +1,22 @@ +/** + * Returns the base URL for the backend API. + * + * Priority: + * 1. Uses NEXT_PUBLIC_API_ORIGIN from environment variables if defined. + * 2. Falls back to the browser's window.location.origin if in a browser environment. + * 3. Defaults to an empty string if executed in a non-browser environment without the env variable. + * + * @returns The backend base URL as a string. + */ +export const backendUrl = () => + process.env.NEXT_PUBLIC_API_ORIGIN || + (typeof window !== 'undefined' ? window.location.origin : ''); + +/** + * Constructs the full base API URL, including the versioned path (e.g., `/api/v1.0/`). + * + * @param apiVersion - The version of the API (defaults to '1.0'). + * @returns The full versioned API base URL as a string. + */ +export const baseApiUrl = (apiVersion: string = '1.0') => + `${backendUrl()}/api/v${apiVersion}/`; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/fetchApi.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/fetchApi.ts new file mode 100644 index 00000000..10d5f1d2 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/fetchApi.ts @@ -0,0 +1,31 @@ +import { baseApiUrl } from './config'; +import { getCSRFToken } from './utils'; + +interface FetchAPIInit extends RequestInit { + withoutContentType?: boolean; +} + +export const fetchAPI = async ( + input: string, + init?: FetchAPIInit, + apiVersion = '1.0', +) => { + const apiUrl = `${baseApiUrl(apiVersion)}${input}`; + const csrfToken = getCSRFToken(); + + const headers = { + 'Content-Type': 'application/json', + ...init?.headers, + ...(csrfToken && { 'X-CSRFToken': csrfToken }), + }; + + if (init?.withoutContentType) { + delete headers?.['Content-Type' as keyof typeof headers]; + } + + return await fetch(apiUrl, { + ...init, + credentials: 'include', + headers, + }); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/helpers.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/helpers.tsx new file mode 100644 index 00000000..e36b9d41 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/helpers.tsx @@ -0,0 +1,56 @@ +import { + DefinedInitialDataInfiniteOptions, + InfiniteData, + QueryKey, + UseQueryOptions, + useInfiniteQuery, +} from '@tanstack/react-query'; + +import { APIError } from './APIError'; +import { APIList } from './types'; + +export type UseQueryOptionsAPI = UseQueryOptions; +export type DefinedInitialDataInfiniteOptionsAPI< + Q, + TPageParam = number, +> = DefinedInitialDataInfiniteOptions< + Q, + APIError, + InfiniteData, + QueryKey, + TPageParam +>; + +/** + * Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests. + * + * @template T - Type of the request parameters. + * @template Q - Type of the API response, which must include an optional `next` field for pagination. + * + * @param {string} key - Unique key to identify the query in the cache. + * @param {(props: T & { page: number }) => Promise} api - Function that fetches paginated data from the API. It receives the params merged with a page number. + * @param {T} param - Static parameters to send with every API request (excluding the page number). + * @param {DefinedInitialDataInfiniteOptionsAPI} [queryConfig] - Optional configuration passed to `useInfiniteQuery` (e.g., stale time, cache time). + * + * @returns Return value of `useInfiniteQuery`, including data, loading state, fetchNextPage, etc. + */ +export const useAPIInfiniteQuery = ['next'] }>( + key: string, + api: (props: T & { page: number }) => Promise, + param: T, + queryConfig?: DefinedInitialDataInfiniteOptionsAPI, +) => { + return useInfiniteQuery, QueryKey, number>({ + initialPageParam: 1, + queryKey: [key, param], + queryFn: ({ pageParam }) => + api({ + ...param, + page: pageParam, + }), + getNextPageParam(lastPage, allPages) { + return lastPage.next ? allPages.length + 1 : undefined; + }, + ...queryConfig, + }); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/index.ts new file mode 100644 index 00000000..1d742adb --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/index.ts @@ -0,0 +1,6 @@ +export * from './APIError'; +export * from './config'; +export * from './fetchApi'; +export * from './helpers'; +export * from './types'; +export * from './utils'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/types.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/types.ts new file mode 100644 index 00000000..e69a61a5 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/types.ts @@ -0,0 +1,20 @@ +/** + * Generic interface representing a paginated API response. + * + * Commonly used for endpoints that return list results with pagination metadata. + * + * @template T - The type of items in the `results` array. + */ +export interface APIList { + /** Total number of items across all pages */ + count: number; + + /** URL to the next page of results, if available (can be null or undefined) */ + next?: string | null; + + /** URL to the previous page of results, if available (can be null or undefined) */ + previous?: string | null; + + /** The list of items for the current page */ + results: T[]; +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/api/utils.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/utils.ts new file mode 100644 index 00000000..82bbe505 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/api/utils.ts @@ -0,0 +1,46 @@ +/** + * Extracts error information from an HTTP `Response` object. + * + * This is typically used to parse structured error responses from an API + * and normalize them into a consistent format with `status`, `cause`, and optional `data`. + * + * @param response - The HTTP response object from `fetch()`. + * @param data - Optional custom data to include with the error output. + * @returns An object containing: + * - `status`: HTTP status code from the response + * - `cause`: A flattened list of error messages, or undefined if no body + * - `data`: The optional data passed in + */ +export const errorCauses = async (response: Response, data?: unknown) => { + const errorsBody = (await response.json()) as Record< + string, + string | string[] + > | null; + + const causes = errorsBody + ? Object.entries(errorsBody) + .map(([, value]) => value) + .flat() + : undefined; + + return { + status: response.status, + cause: causes, + data, + }; +}; + +/** + * Retrieves the CSRF token from the browser's cookies. + * + * Assumes the CSRF token is stored as a cookie named "csrftoken". + * + * @returns The CSRF token string if found, otherwise `undefined`. + */ +export function getCSRFToken() { + return document.cookie + .split(';') + .filter((cookie) => cookie.trim().startsWith('csrftoken=')) + .map((cookie) => cookie.split('=')[1]) + .pop(); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-401.png b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-401.png new file mode 100644 index 00000000..02c7c84d Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-401.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-403.png b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-403.png new file mode 100644 index 00000000..480944f3 Binary files /dev/null and b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-403.png differ diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-404.svg b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-404.svg new file mode 100644 index 00000000..fe705719 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-404.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-docs.svg b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-docs.svg new file mode 100644 index 00000000..882e92ef --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-docs.svg @@ -0,0 +1,12 @@ + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-group.svg b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-group.svg new file mode 100644 index 00000000..c2a7d3c3 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-group.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-group2.svg b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-group2.svg new file mode 100644 index 00000000..845da482 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-group2.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-user.svg b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-user.svg new file mode 100644 index 00000000..ff518b51 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/assets/icons/icon-user.svg @@ -0,0 +1,6 @@ + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Box.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Box.tsx new file mode 100644 index 00000000..84e32057 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Box.tsx @@ -0,0 +1,99 @@ +import { ComponentPropsWithRef, HTMLElementType } from 'react'; +import styled from 'styled-components'; +import { CSSProperties, RuleSet } from 'styled-components/dist/types'; + +import { + MarginPadding, + stylesMargin, + stylesPadding, +} from '@/utils/styleBuilder'; + +import { hideEffect, showEffect } from './Effect'; + +export interface BoxProps { + as?: HTMLElementType; + $align?: CSSProperties['alignItems']; + $background?: CSSProperties['background']; + $color?: CSSProperties['color']; + $css?: string | RuleSet; + $direction?: CSSProperties['flexDirection']; + $display?: CSSProperties['display']; + $effect?: 'show' | 'hide'; + $flex?: CSSProperties['flex']; + $gap?: CSSProperties['gap']; + $hasTransition?: boolean | 'slow'; + $height?: CSSProperties['height']; + $justify?: CSSProperties['justifyContent']; + $opacity?: CSSProperties['opacity']; + $overflow?: CSSProperties['overflow']; + $margin?: MarginPadding; + $maxHeight?: CSSProperties['maxHeight']; + $minHeight?: CSSProperties['minHeight']; + $maxWidth?: CSSProperties['maxWidth']; + $minWidth?: CSSProperties['minWidth']; + $padding?: MarginPadding; + $position?: CSSProperties['position']; + $radius?: CSSProperties['borderRadius']; + $shrink?: CSSProperties['flexShrink']; + $transition?: CSSProperties['transition']; + $width?: CSSProperties['width']; + $wrap?: CSSProperties['flexWrap']; + $zIndex?: CSSProperties['zIndex']; +} + +export type BoxType = ComponentPropsWithRef; + +export const Box = styled('div')` + display: flex; + flex-direction: column; + ${({ $align }) => $align && `align-items: ${$align};`} + ${({ $background }) => $background && `background: ${$background};`} + ${({ $color }) => $color && `color: ${$color};`} + ${({ $direction }) => $direction && `flex-direction: ${$direction};`} + ${({ $display }) => $display && `display: ${$display};`} + ${({ $flex }) => $flex && `flex: ${$flex};`} + ${({ $gap }) => $gap && `gap: ${$gap};`} + ${({ $height }) => $height && `height: ${$height};`} + ${({ $hasTransition }) => + $hasTransition && $hasTransition === 'slow' + ? `transition: all 0.5s ease-in-out;` + : $hasTransition + ? `transition: all 0.3s ease-in-out;` + : ''} + ${({ $justify }) => $justify && `justify-content: ${$justify};`} + ${({ $margin }) => $margin && stylesMargin($margin)} + ${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight};`} + ${({ $minHeight }) => $minHeight && `min-height: ${$minHeight};`} + ${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`} + ${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`} + ${({ $opacity }) => $opacity && `opacity: ${$opacity};`} + ${({ $overflow }) => $overflow && `overflow: ${$overflow};`} + ${({ $padding }) => $padding && stylesPadding($padding)} + ${({ $position }) => $position && `position: ${$position};`} + ${({ $radius }) => $radius && `border-radius: ${$radius};`} + ${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`} + ${({ $transition }) => $transition && `transition: ${$transition};`} + ${({ $width }) => $width && `width: ${$width};`} + ${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`} + ${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)} + ${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`} + ${({ $effect }) => { + let effect; + switch ($effect) { + case 'show': + effect = showEffect; + break; + case 'hide': + effect = hideEffect; + break; + } + + return ( + effect && + ` + transition: all 0.3s ease-in-out; + ${effect} + ` + ); + }} +`; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/BoxButton.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/BoxButton.tsx new file mode 100644 index 00000000..3b8d7751 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/BoxButton.tsx @@ -0,0 +1,60 @@ +import { forwardRef } from 'react'; +import { css } from 'styled-components'; + +import { Box, BoxType } from './Box'; + +export type BoxButtonType = BoxType & { + disabled?: boolean; +}; + +/** + +/** + * Styleless button that extends the Box component. + * Good to wrap around SVGs or other elements that need to be clickable. + * @param props - @see BoxType props + * @param ref + * @see Box + * @example + * ```tsx + * console.log('clicked')}> + * Click me + * + * ``` + */ +const BoxButton = forwardRef( + ({ $css, ...props }, ref) => { + return ( + ) => { + if (props.disabled) { + return; + } + props.onClick?.(event); + }} + /> + ); + }, +); + +BoxButton.displayName = 'BoxButton'; +export { BoxButton }; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Card.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Card.tsx new file mode 100644 index 00000000..9e884bf4 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Card.tsx @@ -0,0 +1,29 @@ +import { PropsWithChildren } from 'react'; +import { css } from 'styled-components'; + +import { useCunninghamTheme } from '@/cunningham'; + +import { Box, BoxType } from '.'; + +export const Card = ({ + children, + $css, + ...props +}: PropsWithChildren) => { + const { colorsTokens } = useCunninghamTheme(); + + return ( + + {children} + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/DropButton.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/DropButton.tsx new file mode 100644 index 00000000..22f18f67 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/DropButton.tsx @@ -0,0 +1,94 @@ +import { + PropsWithChildren, + ReactNode, + useEffect, + useRef, + useState, +} from 'react'; +import { Button, Popover } from 'react-aria-components'; +import styled, { css } from 'styled-components'; + +import { useCunninghamTheme } from '@/cunningham'; + +import { BoxProps } from './Box'; + +const StyledPopover = styled(Popover)` + background-color: white; + border-radius: 4px; + box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1); + border: 1px solid #dddddd; + transition: opacity 0.2s ease-in-out; +`; + +interface StyledButtonProps { + $css?: BoxProps['$css']; +} +const StyledButton = styled(Button)` + cursor: pointer; + border: none; + background: none; + outline: none; + transition: all 0.2s ease-in-out; + font-weight: 500; + font-size: 0.938rem; + padding: 0; + ${({ $css }) => $css}; +`; + +export interface DropButtonProps { + button: ReactNode; + buttonCss?: BoxProps['$css']; + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + label?: string; +} + +export const DropButton = ({ + button, + buttonCss, + isOpen = false, + onOpenChange, + children, + label, +}: PropsWithChildren) => { + const { themeTokens } = useCunninghamTheme(); + const font = themeTokens['font']?.['families']['base']; + const [isLocalOpen, setIsLocalOpen] = useState(isOpen); + + const triggerRef = useRef(null); + + useEffect(() => { + setIsLocalOpen(isOpen); + }, [isOpen]); + + const onOpenChangeHandler = (isOpen: boolean) => { + setIsLocalOpen(isOpen); + onOpenChange?.(isOpen); + }; + + return ( + <> + onOpenChangeHandler(true)} + aria-label={label} + $css={css` + font-family: ${font}; + ${buttonCss}; + `} + className="--docs--drop-button" + > + {button} + + + + {children} + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/DropdownMenu.tsx new file mode 100644 index 00000000..8758588e --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -0,0 +1,175 @@ +import { PropsWithChildren, useRef, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +export type DropdownMenuOption = { + icon?: string; + label: string; + testId?: string; + callback?: () => void | Promise; + danger?: boolean; + isSelected?: boolean; + disabled?: boolean; + show?: boolean; +}; + +export type DropdownMenuProps = { + options: DropdownMenuOption[]; + showArrow?: boolean; + label?: string; + arrowCss?: BoxProps['$css']; + buttonCss?: BoxProps['$css']; + disabled?: boolean; + topMessage?: string; +}; + +export const DropdownMenu = ({ + options, + children, + disabled = false, + showArrow = false, + arrowCss, + buttonCss, + label, + topMessage, +}: PropsWithChildren) => { + const { spacingsTokens, colorsTokens } = useCunninghamTheme(); + const [isOpen, setIsOpen] = useState(false); + const blockButtonRef = useRef(null); + + const onOpenChange = (isOpen: boolean) => { + setIsOpen(isOpen); + }; + + if (disabled) { + return children; + } + + return ( + + {children} + + + ) : ( + + {children} + + ) + } + > + + {topMessage && ( + + {topMessage} + + )} + {options.map((option, index) => { + if (option.show !== undefined && !option.show) { + return; + } + const isDisabled = option.disabled !== undefined && option.disabled; + return ( + { + event.preventDefault(); + event.stopPropagation(); + onOpenChange?.(false); + void option.callback?.(); + }} + key={option.label} + $align="center" + $justify="space-between" + $background={colorsTokens['greyscale-000']} + $color={colorsTokens['primary-600']} + $padding={{ vertical: 'xs', horizontal: 'base' }} + $width="100%" + $gap={spacingsTokens['base']} + $css={css` + border: none; + ${index === 0 && + css` + border-top-left-radius: 4px; + border-top-right-radius: 4px; + `} + ${index === options.length - 1 && + css` + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + `} + font-size: var(--c--theme--font--sizes--sm); + color: var(--c--theme--colors--greyscale-1000); + font-weight: 500; + cursor: ${isDisabled ? 'not-allowed' : 'pointer'}; + user-select: none; + + &:hover { + background-color: var(--c--theme--colors--greyscale-050); + } + `} + > + + {option.icon && ( + + )} + + {option.label} + + + {option.isSelected && ( + + )} + + ); + })} + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Effect.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Effect.tsx new file mode 100644 index 00000000..53cffc2a --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Effect.tsx @@ -0,0 +1,11 @@ +export const showEffect = ` + transform: scaleY(1); + opacity: 1; + max-height: 150px; +`; + +export const hideEffect = ` + transform: scaleY(0); + opacity: 0; + max-height: 0; +`; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Icon.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Icon.tsx new file mode 100644 index 00000000..c99d3f99 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Icon.tsx @@ -0,0 +1,43 @@ +import clsx from 'clsx'; +import { css } from 'styled-components'; + +import { Text, TextType } from '@/components'; + +type IconProps = TextType & { + iconName: string; + variant?: 'filled' | 'outlined'; +}; +export const Icon = ({ + iconName, + variant = 'outlined', + ...textProps +}: IconProps) => { + return ( + + {iconName} + + ); +}; + +type IconOptionsProps = TextType & { + isHorizontal?: boolean; +}; + +export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => { + return ( + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/InfiniteScroll.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/InfiniteScroll.tsx new file mode 100644 index 00000000..5a0ee14b --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/InfiniteScroll.tsx @@ -0,0 +1,51 @@ +import { Button } from '@openfun/cunningham-react'; +import { PropsWithChildren } from 'react'; +import { useTranslation } from 'react-i18next'; +import { InView } from 'react-intersection-observer'; + +import { Box, BoxType, Icon } from '@/components'; + +interface InfiniteScrollProps extends BoxType { + hasMore: boolean; + isLoading: boolean; + next: () => void; + scrollContainer?: HTMLElement | null; + buttonLabel?: string; +} + +export const InfiniteScroll = ({ + children, + hasMore, + isLoading, + next, + buttonLabel, + ...boxProps +}: PropsWithChildren) => { + const { t } = useTranslation(); + const loadMore = (inView: boolean) => { + if (!inView || isLoading) { + return; + } + void next(); + }; + + return ( + + {children} + + {!isLoading && hasMore && ( + + )} + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Link.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Link.tsx new file mode 100644 index 00000000..0c90aef9 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Link.tsx @@ -0,0 +1,16 @@ +import Link from 'next/link'; +import styled, { RuleSet } from 'styled-components'; + +export interface LinkProps { + $css?: string | RuleSet; +} + +export const StyledLink = styled(Link)` + text-decoration: none; + color: #ffffff33; + &[aria-current='page'] { + color: #ffffff; + } + display: flex; + ${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)} +`; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/LoadMoreText.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/LoadMoreText.tsx new file mode 100644 index 00000000..4caa811a --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/LoadMoreText.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; + +import { Box } from './Box'; +import { Icon } from './Icon'; +import { Text } from './Text'; + +type LoadMoreTextProps = { + ['data-testid']?: string; +}; + +export const LoadMoreText = ({ + 'data-testid': dataTestId, +}: LoadMoreTextProps) => { + const { t } = useTranslation(); + + return ( + + + + {t('Load more')} + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/SideModal.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/SideModal.tsx new file mode 100644 index 00000000..d8443040 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/SideModal.tsx @@ -0,0 +1,57 @@ +import { Modal, ModalSize } from '@openfun/cunningham-react'; +import { ComponentPropsWithRef, PropsWithChildren } from 'react'; +import { createGlobalStyle } from 'styled-components'; + +interface SideModalStyleProps { + side: 'left' | 'right'; + width: string; + $css?: string; +} + +const SideModalStyle = createGlobalStyle` + @keyframes slidein { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0%); + } + } + + & .c__modal{ + animation: slidein 0.7s; + + width: ${({ width }) => width}; + ${({ side }) => side === 'right' && 'left: auto;'}; + + .c__modal__scroller { + height: 100%; + display: flex; + flex-direction: column; + } + + ${({ $css }) => $css} + } +`; + +type SideModalType = Omit, 'size'>; + +type SideModalProps = SideModalType & Partial; + +export const SideModal = ({ + children, + side = 'right', + width = '35vw', + $css, + ...modalProps +}: PropsWithChildren) => { + return ( + <> + + + {children} + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Text.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Text.tsx new file mode 100644 index 00000000..83bf0b32 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/Text.tsx @@ -0,0 +1,75 @@ +import { CSSProperties, ComponentPropsWithRef, forwardRef } from 'react'; +import styled from 'styled-components'; + +import { tokens } from '@/cunningham'; + +import { Box, BoxProps } from './Box'; + +const { sizes } = tokens.themes.default.theme.font; +type TextSizes = keyof typeof sizes; + +export interface TextProps extends BoxProps { + as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + $ellipsis?: boolean; + $weight?: CSSProperties['fontWeight']; + $textAlign?: CSSProperties['textAlign']; + $size?: TextSizes | (string & {}); + $theme?: + | 'primary' + | 'primary-text' + | 'secondary' + | 'secondary-text' + | 'info' + | 'success' + | 'warning' + | 'danger' + | 'greyscale'; + $variation?: + | 'text' + | '000' + | '100' + | '200' + | '300' + | '400' + | '500' + | '600' + | '700' + | '800' + | '900' + | '1000'; +} + +export type TextType = ComponentPropsWithRef; + +export const TextStyled = styled(Box)` + ${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`} + ${({ $weight }) => $weight && `font-weight: ${$weight};`} + ${({ $size }) => + $size && + `font-size: ${$size in sizes ? sizes[$size as TextSizes] : $size};`} + ${({ $theme, $variation }) => + `color: var(--c--theme--colors--${$theme}-${$variation});`} + ${({ $color }) => $color && `color: ${$color};`} + ${({ $ellipsis }) => + $ellipsis && + `white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`} +`; + +const Text = forwardRef>( + ({ className, ...props }, ref) => { + return ( + + ); + }, +); + +Text.displayName = 'Text'; + +export { Text }; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/TextErrors.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/TextErrors.tsx new file mode 100644 index 00000000..baf22a2d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/TextErrors.tsx @@ -0,0 +1,64 @@ +import { Alert, VariantType } from '@openfun/cunningham-react'; +import { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import { Box, Text, TextType } from '@/components'; + +const AlertStyled = styled(Alert)` + & .c__button--tertiary:hover { + background-color: var(--c--theme--colors--greyscale-200); + } +`; + +interface TextErrorsProps extends TextType { + causes?: string[]; + defaultMessage?: string; + icon?: ReactNode; + canClose?: boolean; +} + +export const TextErrors = ({ + causes, + defaultMessage, + icon, + canClose = false, + ...textProps +}: TextErrorsProps) => { + const { t } = useTranslation(); + + return ( + + + {causes && + causes.map((cause, i) => ( + + {cause} + + ))} + + {!causes && ( + + {defaultMessage || t('Something bad happens, please retry.')} + + )} + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/__tests__/Box.spec.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/__tests__/Box.spec.tsx new file mode 100644 index 00000000..dc8ca857 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/__tests__/Box.spec.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; + +import { Box } from '../Box'; + +describe('', () => { + it('has the padding from prop', () => { + const { unmount } = render(My Box); + + expect(screen.getByText('My Box')).toHaveStyle('padding: 10px'); + + unmount(); + + render( + + My Box + , + ); + + expect(screen.getByText('My Box')).toHaveStyle(` + padding-left: 2.5rem; + padding-right: 2.5rem; + padding-top: 3rem; + padding-bottom: 0.5rem;`); + }); + + it('has the margin from prop', () => { + const { unmount } = render(My Box); + expect(screen.getByText('My Box')).toHaveStyle('margin: 10px'); + + unmount(); + + render( + + My Box + , + ); + + expect(screen.getByText('My Box')).toHaveStyle(` + margin-left: auto; + margin-right: auto; + margin-top: 1.625rem; + margin-bottom: 100%;`); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/index.ts new file mode 100644 index 00000000..205b7224 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/index.ts @@ -0,0 +1,13 @@ +export * from './Box'; +export * from './BoxButton'; +export * from './Card'; +export * from './DropButton'; +export * from './DropdownMenu'; +export * from './Icon'; +export * from './InfiniteScroll'; +export * from './Link'; +export * from './LoadMoreText'; +export * from './SideModal'; +export * from './separators'; +export * from './Text'; +export * from './TextErrors'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx new file mode 100644 index 00000000..27887927 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx @@ -0,0 +1,71 @@ +import { Command } from 'cmdk'; +import { ReactNode, useRef } from 'react'; + +import { hasChildrens } from '@/utils/children'; + +import { Box } from '../Box'; + +import { QuickSearchInput } from './QuickSearchInput'; +import { QuickSearchStyle } from './QuickSearchStyle'; + +export type QuickSearchAction = { + onSelect?: () => void; + content: ReactNode; +}; + +export type QuickSearchData = { + groupName: string; + elements: T[]; + emptyString?: string; + startActions?: QuickSearchAction[]; + endActions?: QuickSearchAction[]; + showWhenEmpty?: boolean; +}; + +export type QuickSearchProps = { + onFilter?: (str: string) => void; + inputValue?: string; + inputContent?: ReactNode; + showInput?: boolean; + loading?: boolean; + label?: string; + placeholder?: string; + children?: ReactNode; +}; + +export const QuickSearch = ({ + onFilter, + inputContent, + inputValue, + loading, + showInput = true, + label, + placeholder, + children, +}: QuickSearchProps) => { + const ref = useRef(null); + + return ( + <> + +
+ + {showInput && ( + + {inputContent} + + )} + + {children} + + +
+ + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx new file mode 100644 index 00000000..33cffcf8 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx @@ -0,0 +1,66 @@ +import { Command } from 'cmdk'; +import { ReactNode } from 'react'; + +import { Box } from '../Box'; + +import { QuickSearchData } from './QuickSearch'; +import { QuickSearchItem } from './QuickSearchItem'; + +type Props = { + group: QuickSearchData; + renderElement?: (element: T) => ReactNode; + onSelect?: (element: T) => void; +}; + +export const QuickSearchGroup = ({ + group, + onSelect, + renderElement, +}: Props) => { + return ( + + + {group.startActions?.map((action, index) => { + return ( + + {action.content} + + ); + })} + {group.elements.map((groupElement, index) => { + return ( + { + onSelect?.(groupElement); + }} + > + {renderElement?.(groupElement)} + + ); + })} + {group.endActions?.map((action, index) => { + return ( + + {action.content} + + ); + })} + {group.emptyString && group.elements.length === 0 && ( + {group.emptyString} + )} + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx new file mode 100644 index 00000000..9ab52f53 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -0,0 +1,68 @@ +import { Loader } from '@openfun/cunningham-react'; +import { Command } from 'cmdk'; +import { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { HorizontalSeparator } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { Box } from '../Box'; +import { Icon } from '../Icon'; + +type Props = { + loading?: boolean; + inputValue?: string; + onFilter?: (str: string) => void; + placeholder?: string; + children?: ReactNode; + withSeparator?: boolean; +}; +export const QuickSearchInput = ({ + loading, + inputValue, + onFilter, + placeholder, + children, + withSeparator: separator = true, +}: Props) => { + const { t } = useTranslation(); + const { spacingsTokens } = useCunninghamTheme(); + + if (children) { + return ( + <> + {children} + {separator && } + + ); + } + + return ( + <> + + {!loading && } + {loading && ( +
+ +
+ )} + +
+ {separator && } + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx new file mode 100644 index 00000000..b3d7e07c --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx @@ -0,0 +1,18 @@ +import { Command } from 'cmdk'; +import { PropsWithChildren } from 'react'; + +type Props = { + onSelect?: (value: string) => void; + id?: string; +}; +export const QuickSearchItem = ({ + children, + onSelect, + id, +}: PropsWithChildren) => { + return ( + + {children} + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx new file mode 100644 index 00000000..09330bd5 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx @@ -0,0 +1,51 @@ +import { ReactNode } from 'react'; + +import { useCunninghamTheme } from '@/cunningham'; +import { useResponsiveStore } from '@/stores'; + +import { Box } from '../Box'; + +export type QuickSearchItemContentProps = { + alwaysShowRight?: boolean; + left: ReactNode; + right?: ReactNode; +}; + +export const QuickSearchItemContent = ({ + alwaysShowRight = false, + left, + right, +}: QuickSearchItemContentProps) => { + const { spacingsTokens } = useCunninghamTheme(); + + const { isDesktop } = useResponsiveStore(); + + return ( + + + {left} + + + {isDesktop && right && ( + + {right} + + )} + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx new file mode 100644 index 00000000..b6fa0ad6 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx @@ -0,0 +1,139 @@ +import { createGlobalStyle } from 'styled-components'; + +export const QuickSearchStyle = createGlobalStyle` + .quick-search-container { + [cmdk-root] { + width: 100%; + background: #ffffff; + border-radius: 12px; + overflow: hidden; + transition: transform 100ms ease; + outline: none; + } + + [cmdk-input] { + border: none; + width: 100%; + font-size: 17px; + padding: 8px; + background: white; + outline: none; + color: var(--c--theme--colors--greyscale-1000); + border-radius: 0; + + &::placeholder { + color: var(--c--theme--colors--greyscale-500); + } + } + + + + [cmdk-item] { + content-visibility: auto; + cursor: pointer; + border-radius: var(--c--theme--spacings--xs); + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + user-select: none; + will-change: background, color; + transition: all 150ms ease; + transition-property: none; + + .show-right-on-focus { + opacity: 0; + } + + &:hover, + &[data-selected='true'] { + background: var(--c--theme--colors--greyscale-100); + .show-right-on-focus { + opacity: 1; + } + } + + &[data-disabled='true'] { + color: var(--c--theme--colors--greyscale-500); + cursor: not-allowed; + } + + & + [cmdk-item] { + margin-top: 4px; + } + } + + [cmdk-list] { + + padding: 0 var(--c--theme--spacings--base) var(--c--theme--spacings--base) + var(--c--theme--spacings--base); + + flex:1; + overflow-y: auto; + overscroll-behavior: contain; + } + + [cmdk-vercel-shortcuts] { + display: flex; + margin-left: auto; + gap: 8px; + + kbd { + font-size: 12px; + min-width: 20px; + padding: 4px; + height: 20px; + border-radius: 4px; + color: white; + background: var(--c--theme--colors--greyscale-500); + display: inline-flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + } + } + + [cmdk-separator] { + height: 1px; + width: 100%; + background: var(--c--theme--colors--greyscale-500); + margin: 4px 0; + } + + *:not([hidden]) + [cmdk-group] { + margin-top: 8px; + } + + [cmdk-group-heading] { + user-select: none; + font-size: var(--c--theme--font--sizes--sm); + color: var(--c--theme--colors--greyscale-700); + font-weight: bold; + + display: flex; + align-items: center; + margin-bottom: var(--c--theme--spacings--xs); + } + + [cmdk-empty] { + } +} + +.c__modal__scroller:has(.quick-search-container), +.c__modal__scroller:has(.noPadding) { + padding: 0 !important; + + .c__modal__close .c__button { + right: 5px; + top: 5px; + padding: 1.5rem 1rem; + } + + .c__modal__title { + font-size: var(--c--theme--font--sizes--xs); + + padding: var(--c--theme--spacings--base); + margin-bottom: 0; + } +} +`; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/index.ts new file mode 100644 index 00000000..140e592d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/quick-search/index.ts @@ -0,0 +1,4 @@ +export * from './QuickSearch'; +export * from './QuickSearchGroup'; +export * from './QuickSearchItem'; +export * from './QuickSearchItemContent'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/HorizontalSeparator.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/HorizontalSeparator.tsx new file mode 100644 index 00000000..f8ab9bd1 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/HorizontalSeparator.tsx @@ -0,0 +1,34 @@ +import { useCunninghamTheme } from '@/cunningham'; + +import { Box } from '../Box'; + +export enum SeparatorVariant { + LIGHT = 'light', + DARK = 'dark', +} + +type Props = { + variant?: SeparatorVariant; + $withPadding?: boolean; +}; + +export const HorizontalSeparator = ({ + variant = SeparatorVariant.LIGHT, + $withPadding = true, +}: Props) => { + const { colorsTokens } = useCunninghamTheme(); + + return ( + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/SeparatedSection.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/SeparatedSection.tsx new file mode 100644 index 00000000..0411f5b0 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/SeparatedSection.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren } from 'react'; +import { css } from 'styled-components'; + +import { useCunninghamTheme } from '@/cunningham'; + +import { Box } from '../Box'; + +type Props = { + showSeparator?: boolean; +}; + +export const SeparatedSection = ({ + showSeparator = true, + children, +}: PropsWithChildren) => { + const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + return ( + + {children} + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/index.ts new file mode 100644 index 00000000..f8d2100c --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/components/separators/index.ts @@ -0,0 +1,2 @@ +export * from './HorizontalSeparator'; +export * from './SeparatedSection'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/AppProvider.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/AppProvider.tsx new file mode 100644 index 00000000..03ce5097 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -0,0 +1,71 @@ +import { CunninghamProvider } from '@openfun/cunningham-react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +import { useCunninghamTheme } from '@/cunningham'; +import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth'; +import { useResponsiveStore } from '@/stores/'; + +import { ConfigProvider } from './config/'; + +/** + * QueryClient: + * - defaultOptions: + * - staleTime: + * - global cache duration - we decided 3 minutes + * - It can be overridden to each query + */ +const defaultOptions = { + queries: { + staleTime: 1000 * 60 * 3, + retry: 1, + }, +}; +const queryClient = new QueryClient({ + defaultOptions, +}); + +export function AppProvider({ children }: { children: React.ReactNode }) { + const { theme } = useCunninghamTheme(); + const { replace } = useRouter(); + + const initializeResizeListener = useResponsiveStore( + (state) => state.initializeResizeListener, + ); + + useEffect(() => { + return initializeResizeListener(); + }, [initializeResizeListener]); + + useEffect(() => { + queryClient.setDefaultOptions({ + ...defaultOptions, + mutations: { + onError: (error) => { + if ( + error instanceof Error && + 'status' in error && + error.status === 401 + ) { + void queryClient.resetQueries({ + queryKey: [KEY_AUTH], + }); + setAuthUrl(); + void replace(`/401`); + } + }, + }, + }); + }, [replace]); + + return ( + + + + {children} + + + + ); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx new file mode 100644 index 00000000..371e7c35 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -0,0 +1,71 @@ +import { Loader } from '@openfun/cunningham-react'; +import Head from 'next/head'; +import { PropsWithChildren, useEffect } from 'react'; + +import { Box } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { useLanguageSynchronizer } from '@/features/language/'; +import { useAnalytics } from '@/libs'; +import { CrispProvider, PostHogAnalytic } from '@/services'; +import { useSentryStore } from '@/stores/useSentryStore'; + +import { useConfig } from './api/useConfig'; + +export const ConfigProvider = ({ children }: PropsWithChildren) => { + const { data: conf } = useConfig(); + const { setSentry } = useSentryStore(); + const { setTheme } = useCunninghamTheme(); + const { AnalyticsProvider } = useAnalytics(); + const { synchronizeLanguage } = useLanguageSynchronizer(); + + useEffect(() => { + if (!conf?.SENTRY_DSN) { + return; + } + + setSentry(conf.SENTRY_DSN, conf.ENVIRONMENT); + }, [conf?.SENTRY_DSN, conf?.ENVIRONMENT, setSentry]); + + useEffect(() => { + if (!conf?.FRONTEND_THEME) { + return; + } + + setTheme(conf.FRONTEND_THEME); + }, [conf?.FRONTEND_THEME, setTheme]); + + useEffect(() => { + void synchronizeLanguage(); + }, [synchronizeLanguage]); + + useEffect(() => { + if (!conf?.POSTHOG_KEY) { + return; + } + + new PostHogAnalytic(conf.POSTHOG_KEY); + }, [conf?.POSTHOG_KEY]); + + if (!conf) { + return ( + + + + ); + } + + return ( + <> + {conf?.FRONTEND_CSS_URL && ( + + + + )} + + + {children} + + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/api/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/api/index.ts new file mode 100644 index 00000000..4852f577 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/api/index.ts @@ -0,0 +1 @@ +export * from './useConfig'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/api/useConfig.tsx new file mode 100644 index 00000000..761c588c --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -0,0 +1,70 @@ +import { useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { Theme } from '@/cunningham/'; +import { FooterType } from '@/features/footer'; +import { PostHogConf } from '@/services'; + +interface ThemeCustomization { + footer?: FooterType; +} + +interface ConfigResponse { + AI_FEATURE_ENABLED?: boolean; + COLLABORATION_WS_URL?: string; + COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; + CRISP_WEBSITE_ID?: string; + ENVIRONMENT: string; + FRONTEND_CSS_URL?: string; + FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean; + FRONTEND_THEME?: Theme; + LANGUAGES: [string, string][]; + LANGUAGE_CODE: string; + MEDIA_BASE_URL?: string; + POSTHOG_KEY?: PostHogConf; + SENTRY_DSN?: string; + theme_customization?: ThemeCustomization; +} + +const LOCAL_STORAGE_KEY = 'docs_config'; + +function getCachedTranslation() { + try { + const jsonString = localStorage.getItem(LOCAL_STORAGE_KEY); + return jsonString ? (JSON.parse(jsonString) as ConfigResponse) : undefined; + } catch { + return undefined; + } +} + +function setCachedTranslation(translations: ConfigResponse) { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(translations)); +} + +export const getConfig = async (): Promise => { + const response = await fetchAPI(`config/`); + + if (!response.ok) { + throw new APIError('Failed to get the doc', await errorCauses(response)); + } + + const config = response.json() as Promise; + setCachedTranslation(await config); + + return config; +}; + +export const KEY_CONFIG = 'config'; + +export function useConfig() { + const cachedData = getCachedTranslation(); + const oneHour = 1000 * 60 * 60; + + return useQuery({ + queryKey: [KEY_CONFIG], + queryFn: () => getConfig(), + initialData: cachedData, + staleTime: oneHour, + initialDataUpdatedAt: Date.now() - oneHour, // Force initial data to be considered stale + }); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/index.ts new file mode 100644 index 00000000..e1b2dff2 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useMediaUrl'; +export * from './useCollaborationUrl'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx new file mode 100644 index 00000000..b0668372 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx @@ -0,0 +1,17 @@ +import { useConfig } from '../api'; + +export const useCollaborationUrl = (room?: string) => { + const { data: conf } = useConfig(); + + if (!room) { + return; + } + + const base = + conf?.COLLABORATION_WS_URL || + (typeof window !== 'undefined' + ? `wss://${window.location.host}/collaboration/ws/` + : ''); + + return `${base}?room=${room}`; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/useMediaUrl.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/useMediaUrl.tsx new file mode 100644 index 00000000..1fcb5ec5 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/hooks/useMediaUrl.tsx @@ -0,0 +1,10 @@ +import { useConfig } from '../api'; + +export const useMediaUrl = () => { + const { data: conf } = useConfig(); + + return ( + conf?.MEDIA_BASE_URL || + (typeof window !== 'undefined' ? window.location.origin : '') + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/index.ts new file mode 100644 index 00000000..e786d397 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/config/index.ts @@ -0,0 +1,3 @@ +export * from './api/'; +export * from './ConfigProvider'; +export * from './hooks'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/core/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/index.ts new file mode 100644 index 00000000..831279fd --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/core/index.ts @@ -0,0 +1,2 @@ +export * from './AppProvider'; +export * from './config'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/__tests__/useCunninghamTheme.spec.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/__tests__/useCunninghamTheme.spec.tsx new file mode 100644 index 00000000..208d8942 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/__tests__/useCunninghamTheme.spec.tsx @@ -0,0 +1,16 @@ +import { useCunninghamTheme } from '../useCunninghamTheme'; + +describe('', () => { + it('has the logo correctly set', () => { + expect(useCunninghamTheme.getState().themeTokens.logo?.src).toBe(''); + + // Change theme + useCunninghamTheme.getState().setTheme('dsfr'); + + const { themeTokens } = useCunninghamTheme.getState(); + const logo = themeTokens.logo; + expect(logo?.src).toBe('/assets/logo-gouv.svg'); + expect(logo?.widthHeader).toBe('110px'); + expect(logo?.widthFooter).toBe('220px'); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-style.css b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-style.css new file mode 100644 index 00000000..8087717f --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-style.css @@ -0,0 +1,78 @@ +@import url('@gouvfr-lasuite/ui-kit/style'); +@import url('./cunningham-tokens.css'); + +:root { + /** + * Input + */ + --c--components--forms-input--border-radius--hover: var( + --c--components--forms-input--border-radius + ); + --c--components--forms-input--border-radius--focus: var( + --c--components--forms-input--border-radius + ); + --c--components--forms-input--border-color--hover: var( + --c--components--forms-input--border-color + ); + + /** + * Datepicker + **/ + --c--components--forms-datepicker--border-color--hover: var( + --c--components--forms-datepicker--border-color + ); + + /** + * Select + **/ + --c--components--forms-select--value-color--disabled: var( + --c--theme--colors--greyscale-400 + ); + + /** + * Button + **/ + --c--components--button--border-radius--active: var( + --c--components--button--border-radius + ); +} + +/** + * Tooltip +*/ +.c__tooltip { + padding: 4px 6px; +} + +/** + * Image System +*/ +.c__image-system-filter { + filter: var(--c--components--image-system-filter); +} + +@font-face { + font-family: Inter; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: url('https://fonts.gstatic.com/s/inter/v18/UcCm3FwrK3iLTcvnUwQT9g.woff2') + format('woff2'); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: Inter; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.woff2') + format('woff2'); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css new file mode 100644 index 00000000..2fdb9326 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css @@ -0,0 +1,2767 @@ +:root { + --c--theme--colors--secondary-text: #fff; + --c--theme--colors--secondary-100: #fee9ea; + --c--theme--colors--secondary-200: #fedfdf; + --c--theme--colors--secondary-300: #fdbfbf; + --c--theme--colors--secondary-400: #e1020f; + --c--theme--colors--secondary-500: #c91a1f; + --c--theme--colors--secondary-600: #5e2b2b; + --c--theme--colors--secondary-700: #3b2424; + --c--theme--colors--secondary-800: #341f1f; + --c--theme--colors--secondary-900: #2b1919; + --c--theme--colors--info-text: #0078f3; + --c--theme--colors--info-100: #e8edff; + --c--theme--colors--info-200: #dde5ff; + --c--theme--colors--info-300: #bccdff; + --c--theme--colors--info-400: #518fff; + --c--theme--colors--info-500: #0078f3; + --c--theme--colors--info-600: #0063cb; + --c--theme--colors--info-700: #273961; + --c--theme--colors--info-800: #222a3f; + --c--theme--colors--info-900: #1d2437; + --c--theme--colors--greyscale-100: #eee; + --c--theme--colors--greyscale-200: #e5e5e5; + --c--theme--colors--greyscale-300: #cecece; + --c--theme--colors--greyscale-400: #929292; + --c--theme--colors--greyscale-500: #7c7c7c; + --c--theme--colors--greyscale-600: #666; + --c--theme--colors--greyscale-700: #3a3a3a; + --c--theme--colors--greyscale-800: #2a2a2a; + --c--theme--colors--greyscale-900: #242424; + --c--theme--colors--greyscale-000: #fff; + --c--theme--colors--primary-100: #ececfe; + --c--theme--colors--primary-200: #e3e3fd; + --c--theme--colors--primary-300: #cacafb; + --c--theme--colors--primary-400: #8585f6; + --c--theme--colors--primary-500: #6a6af4; + --c--theme--colors--primary-600: #313178; + --c--theme--colors--primary-700: #272747; + --c--theme--colors--primary-800: #000091; + --c--theme--colors--primary-900: #21213f; + --c--theme--colors--success-100: #dffee6; + --c--theme--colors--success-200: #b8fec9; + --c--theme--colors--success-300: #88fdaa; + --c--theme--colors--success-400: #3bea7e; + --c--theme--colors--success-500: #1f8d49; + --c--theme--colors--success-600: #18753c; + --c--theme--colors--success-700: #204129; + --c--theme--colors--success-800: #1e2e22; + --c--theme--colors--success-900: #19281d; + --c--theme--colors--warning-100: #fff4f3; + --c--theme--colors--warning-200: #ffe9e6; + --c--theme--colors--warning-300: #ffded9; + --c--theme--colors--warning-400: #ffbeb4; + --c--theme--colors--warning-500: #d64d00; + --c--theme--colors--warning-600: #b34000; + --c--theme--colors--warning-700: #5e2c21; + --c--theme--colors--warning-800: #3e241e; + --c--theme--colors--warning-900: #361e19; + --c--theme--colors--danger-100: #ffe9e9; + --c--theme--colors--danger-200: #fdd; + --c--theme--colors--danger-300: #ffbdbd; + --c--theme--colors--danger-400: #ff5655; + --c--theme--colors--danger-500: #f60700; + --c--theme--colors--danger-600: #ce0500; + --c--theme--colors--danger-700: #642626; + --c--theme--colors--danger-800: #412121; + --c--theme--colors--danger-900: #391c1c; + --c--theme--colors--primary-text: #000091; + --c--theme--colors--success-text: #1f8d49; + --c--theme--colors--warning-text: #d64d00; + --c--theme--colors--danger-text: #fff; + --c--theme--colors--primary-050: #f5f5fe; + --c--theme--colors--primary-150: #f4f4fd; + --c--theme--colors--greyscale-text: #303c4b; + --c--theme--colors--greyscale-050: #f6f6f6; + --c--theme--colors--greyscale-250: #ddd; + --c--theme--colors--greyscale-350: #ddd; + --c--theme--colors--greyscale-750: #353535; + --c--theme--colors--greyscale-950: #1e1e1e; + --c--theme--colors--greyscale-1000: #161616; + --c--theme--colors--danger-050: #fff4f4; + --c--theme--colors--blue-500: #417dc4; + --c--theme--colors--brown-500: #bd987a; + --c--theme--colors--cyan-500: #009099; + --c--theme--colors--gold-500: #c3992a; + --c--theme--colors--green-500: #00a95f; + --c--theme--colors--olive-500: #68a532; + --c--theme--colors--orange-500: #e4794a; + --c--theme--colors--purple-500: #a558a0; + --c--theme--colors--red-500: #e1000f; + --c--theme--colors--yellow-500: #b7a73f; + --c--theme--colors--rose-500: #e18b76; + --c--theme--colors--primary-action: #1212ff; + --c--theme--colors--primary-bg: #fafafa; + --c--theme--colors--primary-focus: #0a76f6; + --c--theme--colors--secondary-icon: var(--c--theme--colors--primary-text); + --c--theme--colors--blue-400: #7ab1e8; + --c--theme--colors--blue-600: #3558a2; + --c--theme--colors--brown-400: #e6be92; + --c--theme--colors--brown-600: #745b47; + --c--theme--colors--cyan-400: #34bab5; + --c--theme--colors--cyan-600: #006a6f; + --c--theme--colors--gold-400: #ffca00; + --c--theme--colors--gold-600: #695240; + --c--theme--colors--green-400: #34cb6a; + --c--theme--colors--green-600: #297254; + --c--theme--colors--olive-400: #99c221; + --c--theme--colors--olive-600: #447049; + --c--theme--colors--orange-400: #ff732c; + --c--theme--colors--orange-600: #755348; + --c--theme--colors--pink-400: #ffb7ae; + --c--theme--colors--pink-500: #e18b76; + --c--theme--colors--pink-600: #8d533e; + --c--theme--colors--purple-400: #ce70cc; + --c--theme--colors--purple-600: #6e445a; + --c--theme--colors--yellow-400: #d8c634; + --c--theme--colors--yellow-600: #66673d; + --c--theme--font--sizes--h1: 2rem; + --c--theme--font--sizes--h2: 1.75rem; + --c--theme--font--sizes--h3: 1.5rem; + --c--theme--font--sizes--h4: 1.375rem; + --c--theme--font--sizes--h5: 1.25rem; + --c--theme--font--sizes--h6: 1.125rem; + --c--theme--font--sizes--l: 1rem; + --c--theme--font--sizes--m: 0.8125rem; + --c--theme--font--sizes--s: 0.75rem; + --c--theme--font--sizes--xs: 0.75rem; + --c--theme--font--sizes--sm: 0.875rem; + --c--theme--font--sizes--md: 1rem; + --c--theme--font--sizes--lg: 1.125rem; + --c--theme--font--sizes--ml: 0.938rem; + --c--theme--font--sizes--xl: 1.25rem; + --c--theme--font--sizes--t: 0.6875rem; + --c--theme--font--sizes--xl-alt: 5rem; + --c--theme--font--sizes--lg-alt: 4.5rem; + --c--theme--font--sizes--md-alt: 4rem; + --c--theme--font--sizes--sm-alt: 3.5rem; + --c--theme--font--sizes--xs-alt: 3rem; + --c--theme--font--weights--thin: 100; + --c--theme--font--weights--light: 300; + --c--theme--font--weights--regular: 400; + --c--theme--font--weights--medium: 500; + --c--theme--font--weights--bold: 600; + --c--theme--font--weights--extrabold: 800; + --c--theme--font--weights--black: 900; + --c--theme--font--families--base: marianne; + --c--theme--font--families--accent: marianne; + --c--theme--font--letterspacings--h1: normal; + --c--theme--font--letterspacings--h2: normal; + --c--theme--font--letterspacings--h3: normal; + --c--theme--font--letterspacings--h4: normal; + --c--theme--font--letterspacings--h5: 1px; + --c--theme--font--letterspacings--h6: normal; + --c--theme--font--letterspacings--l: normal; + --c--theme--font--letterspacings--m: normal; + --c--theme--font--letterspacings--s: normal; + --c--theme--spacings--0: 0; + --c--theme--spacings--xl: 2.5rem; + --c--theme--spacings--l: 3rem; + --c--theme--spacings--b: 1.625rem; + --c--theme--spacings--s: 1rem; + --c--theme--spacings--t: 0.5rem; + --c--theme--spacings--st: 0.25rem; + --c--theme--spacings--none: 0; + --c--theme--spacings--auto: auto; + --c--theme--spacings--bx: 2.2rem; + --c--theme--spacings--full: 100%; + --c--theme--spacings--4xs: 0.125rem; + --c--theme--spacings--3xs: 0.25rem; + --c--theme--spacings--2xs: 0.375rem; + --c--theme--spacings--xs: 0.5rem; + --c--theme--spacings--sm: 0.75rem; + --c--theme--spacings--base: 1rem; + --c--theme--spacings--md: 1.5rem; + --c--theme--spacings--lg: 2rem; + --c--theme--spacings--xxl: 3rem; + --c--theme--spacings--xxxl: 3.5rem; + --c--theme--spacings--4xl: 4rem; + --c--theme--spacings--5xl: 4.5rem; + --c--theme--spacings--6xl: 6rem; + --c--theme--spacings--7xl: 7.5rem; + --c--theme--transitions--ease-in: cubic-bezier(0.32, 0, 0.67, 0); + --c--theme--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1); + --c--theme--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --c--theme--transitions--duration: 250ms; + --c--theme--breakpoints--xs: 480px; + --c--theme--breakpoints--sm: 576px; + --c--theme--breakpoints--md: 768px; + --c--theme--breakpoints--lg: 992px; + --c--theme--breakpoints--xl: 1200px; + --c--theme--breakpoints--xxl: 1400px; + --c--theme--breakpoints--xxs: 320px; + --c--theme--breakpoints--mobile: 768px; + --c--theme--breakpoints--tablet: 1024px; + --c--theme--logo--src: ; + --c--theme--logo--alt: ; + --c--theme--logo--widthheader: ; + --c--theme--logo--widthfooter: ; + --c--components--modal--width-small: 342px; + --c--components--tooltip--padding: 4px 8px; + --c--components--tooltip--background-color: var( + --c--theme--colors--greyscale-1000 + ); + --c--components--button--medium-height: 40px; + --c--components--button--medium-text-height: 40px; + --c--components--button--border-radius: 4px; + --c--components--button--small-height: 26px; + --c--components--button--primary--background--color: var( + --c--theme--colors--primary-text + ); + --c--components--button--primary--background--color-hover: #1212ff; + --c--components--button--primary--background--color-active: #2323ff; + --c--components--button--primary--background--color-disabled: var( + --c--theme--colors--greyscale-100 + ); + --c--components--button--primary--color: #fff; + --c--components--button--primary--color-hover: #fff; + --c--components--button--primary--color-active: #fff; + --c--components--button--primary--color-focus-visible: #fff; + --c--components--button--primary--disabled: var( + --c--theme--colors--greyscale-500 + ); + --c--components--button--primary-text--background--color: var( + --c--theme--colors--primary-text + ); + --c--components--button--primary-text--background--color-hover: var( + --c--theme--colors--greyscale-100 + ); + --c--components--button--primary-text--background--color-active: var( + --c--theme--colors--primary-100 + ); + --c--components--button--primary-text--background--color-focus-visible: #fff; + --c--components--button--primary-text--background--color-disabled: var( + --c--theme--colors--greyscale-000 + ); + --c--components--button--primary-text--color: var( + --c--theme--colors--primary-800 + ); + --c--components--button--primary-text--color-hover: var( + --c--theme--colors--primary-800 + ); + --c--components--button--primary-text--disabled: var( + --c--theme--colors--greyscale-400 + ); + --c--components--button--secondary--background--color-hover: #f6f6f6; + --c--components--button--secondary--background--color-active: #ededed; + --c--components--button--secondary--background--color-focus-visible: var( + --c--theme--colors--greyscale-000 + ); + --c--components--button--secondary--background--disabled: var( + --c--theme--colors--greyscale-000 + ); + --c--components--button--secondary--color: var( + --c--theme--colors--primary-800 + ); + --c--components--button--secondary--border--color: var( + --c--theme--colors--greyscale-300 + ); + --c--components--button--secondary--border--color-hover: var( + --c--theme--colors--greyscale-300 + ); + --c--components--button--secondary--border--color-disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--button--secondary--disabled: var( + --c--theme--colors--greyscale-400 + ); + --c--components--button--tertiary--background--color: var( + --c--theme--colors--primary-100 + ); + --c--components--button--tertiary--background--color-focus-visible: var( + --c--theme--colors--primary-100 + ); + --c--components--button--tertiary--background--color-hover: var( + --c--theme--colors--primary-300 + ); + --c--components--button--tertiary--background--color-active: var( + --c--theme--colors--primary-300 + ); + --c--components--button--tertiary--background--disabled: var( + --c--theme--colors--primary-050 + ); + --c--components--button--tertiary--color: var( + --c--theme--colors--primary-800 + ); + --c--components--button--tertiary--disabled: var( + --c--theme--colors--primary-300 + ); + --c--components--button--tertiary-text--background--color-hover: var( + --c--theme--colors--greyscale-100 + ); + --c--components--button--tertiary-text--color-hover: var( + --c--theme--colors--primary-text + ); + --c--components--button--tertiary-text--color: var( + --c--theme--colors--primary-600 + ); + --c--components--button--danger--color-hover: white; + --c--components--button--danger--background--color: var( + --c--theme--colors--danger-600 + ); + --c--components--button--danger--background--color-hover: #ff2725; + --c--components--button--danger--background--color-focus-visible: var( + --c--theme--colors--danger-600 + ); + --c--components--button--danger--background--color-disabled: var( + --c--theme--colors--greyscale-100 + ); + --c--components--button--danger--color-disabled: var( + --c--theme--colors--greyscale-400 + ); + --c--components--datagrid--header--color: var( + --c--theme--colors--greyscale-600 + ); + --c--components--datagrid--header--size: 12px; + --c--components--datagrid--header--weight: 500; + --c--components--datagrid--body--background-color-hover: var( + --c--theme--colors--greyscale-100 + ); + --c--components--forms-checkbox--border-radius: 4px; + --c--components--forms-checkbox--border-color: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-checkbox--background-color--hover: var( + --c--theme--colors--greyscale-100 + ); + --c--components--forms-checkbox--border--color-disabled: var( + --c--theme--colors--greyscale-200 + ); + --c--components--forms-checkbox--border--color: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-checkbox--background--disabled: var( + --c--theme--colors--greyscale-200 + ); + --c--components--forms-checkbox--background--enable: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-checkbox--check--disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--forms-checkbox--check--enable: var( + --c--theme--colors--greyscale-000 + ); + --c--components--forms-checkbox--color: var(--c--theme--colors--primary-text); + --c--components--forms-checkbox--label--color: var( + --c--theme--colors--greyscale-1000 + ); + --c--components--forms-checkbox--label--size: var( + --c--theme--font--sizes--sm + ); + --c--components--forms-checkbox--label--weight: 500; + --c--components--forms-checkbox--text--color: var( + --c--theme--colors--greyscale-600 + ); + --c--components--forms-checkbox--text--size: var(--c--theme--font--sizes--s); + --c--components--forms-checkbox--text--weight: 400; + --c--components--forms-checkbox--text--color-disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--forms-labelledbox--label-color--small: var( + --c--theme--colors--greyscale-950 + ); + --c--components--forms-labelledbox--label-color--small--disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--forms-labelledbox--label-color--big: var( + --c--theme--colors--greyscale-950 + ); + --c--components--forms-labelledbox--label-color--big--disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--forms-radio--border-color: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-radio--background-color: var( + --c--theme--colors--greyscale-000 + ); + --c--components--forms-radio--accent-color: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-radio--accent-color-disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--forms-switch--border--color-disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--forms-switch--border--color: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-switch--handle-background-color: white; + --c--components--forms-switch--handle-background-color--disabled: var( + --c--theme--colors--greyscale-000 + ); + --c--components--forms-switch--rail-background-color--disabled: var( + --c--theme--colors--greyscale-000 + ); + --c--components--forms-switch--accent-color: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-textarea--label-color--focus: var( + --c--theme--colors--greyscale-1000 + ); + --c--components--forms-textarea--border-radius: 4px; + --c--components--forms-textarea--border-color: var( + --c--theme--colors--greyscale-400 + ); + --c--components--forms-textarea--box-shadow--color--hover: var( + --c--theme--colors--greyscale-400 + ); + --c--components--forms-textarea--box-shadow--color--focus: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-textarea--value-color: var( + --c--theme--colors--greyscale-950 + ); + --c--components--forms-textarea--value-color--disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--forms-textarea--font-size: 14px; + --c--components--forms-input--label-color--focus: var( + --c--theme--colors--greyscale-1000 + ); + --c--components--forms-input--border-radius: 4px; + --c--components--forms-input--border-color: var( + --c--theme--colors--greyscale-400 + ); + --c--components--forms-input--box-shadow--color--hover: var( + --c--theme--colors--greyscale-400 + ); + --c--components--forms-input--box-shadow--color--focus: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-input--value-color: var( + --c--theme--colors--greyscale-950 + ); + --c--components--forms-input--value-color--disabled: var( + --c--theme--colors--greyscale-300 + ); + --c--components--forms-input--font-size: 14px; + --c--components--forms-select--label-color--focus: var( + --c--theme--colors--greyscale-1000 + ); + --c--components--forms-select--item-font-size: 14px; + --c--components--forms-select--border-radius: 4px; + --c--components--forms-select--border-radius-hover: 4px; + --c--components--forms-select--border-color: var( + --c--theme--colors--greyscale-400 + ); + --c--components--forms-select--box-shadow--color--hover: var( + --c--theme--colors--greyscale-400 + ); + --c--components--forms-select--box-shadow--color--focus: var( + --c--theme--colors--primary-800 + ); + --c--components--forms-select--value-color: var( + --c--theme--colors--greyscale-950 + ); + --c--components--forms-select--font-size: 14px; + --c--components--badge--font-size: var(--c--theme--font--sizes--xs); + --c--components--badge--border-radius: 4px; + --c--components--badge--padding-inline: var(--c--theme--spacings--xs); + --c--components--badge--padding-block: var(--c--theme--spacings--2xs); + --c--components--badge--accent--background-color: var( + --c--theme--colors--primary-100 + ); + --c--components--badge--accent--color: var(--c--theme--colors--primary-600); + --c--components--badge--neutral--background-color: var( + --c--theme--colors--greyscale-100 + ); + --c--components--badge--neutral--color: var( + --c--theme--colors--greyscale-600 + ); + --c--components--badge--danger--background-color: var( + --c--theme--colors--danger-100 + ); + --c--components--badge--danger--color: var(--c--theme--colors--danger-600); + --c--components--badge--success--background-color: var( + --c--theme--colors--success-100 + ); + --c--components--badge--success--color: var(--c--theme--colors--success-600); + --c--components--badge--warning--background-color: var( + --c--theme--colors--warning-100 + ); + --c--components--badge--warning--color: var(--c--theme--colors--warning-600); + --c--components--badge--info--background-color: var( + --c--theme--colors--info-100 + ); + --c--components--badge--info--color: var(--c--theme--colors--info-600); + --c--components--la-gaufre: false; + --c--components--home-proconnect: false; + --c--components--beta: false; + --c--components--image-system-filter: ; + --c--components--favicon--ico: /assets/favicon-light.ico; + --c--components--favicon--png-light: /assets/favicon-light.png; + --c--components--favicon--png-dark: /assets/favicon-dark.png; +} + +.cunningham-theme--dark { + --c--theme--colors--greyscale-100: #182536; + --c--theme--colors--greyscale-200: #303c4b; + --c--theme--colors--greyscale-300: #555f6b; + --c--theme--colors--greyscale-400: #79818a; + --c--theme--colors--greyscale-500: #9ea3aa; + --c--theme--colors--greyscale-600: #c2c6ca; + --c--theme--colors--greyscale-700: #e7e8ea; + --c--theme--colors--greyscale-800: #f3f4f4; + --c--theme--colors--greyscale-900: #fafafb; + --c--theme--colors--greyscale-000: #0c1a2b; + --c--theme--colors--primary-100: #3b4c62; + --c--theme--colors--primary-200: #4d6481; + --c--theme--colors--primary-300: #6381a6; + --c--theme--colors--primary-400: #7fa5d5; + --c--theme--colors--primary-500: #8cb5ea; + --c--theme--colors--primary-600: #a3c4ee; + --c--theme--colors--primary-700: #c3d8f4; + --c--theme--colors--primary-800: #dde9f8; + --c--theme--colors--primary-900: #f4f8fd; + --c--theme--colors--success-100: #eef8d7; + --c--theme--colors--success-200: #d9f1b2; + --c--theme--colors--success-300: #bde985; + --c--theme--colors--success-400: #a0e25d; + --c--theme--colors--success-500: #76d628; + --c--theme--colors--success-600: #5bb520; + --c--theme--colors--success-700: #43941a; + --c--theme--colors--success-800: #307414; + --c--theme--colors--success-900: #225d10; + --c--theme--colors--warning-100: #f7f3d5; + --c--theme--colors--warning-200: #f0e5aa; + --c--theme--colors--warning-300: #e8d680; + --c--theme--colors--warning-400: #e3c95f; + --c--theme--colors--warning-500: #d9b32b; + --c--theme--colors--warning-600: #bd9721; + --c--theme--colors--warning-700: #9d7b1c; + --c--theme--colors--warning-800: #7e6016; + --c--theme--colors--warning-900: #684d12; + --c--theme--colors--danger-100: #f8d0d0; + --c--theme--colors--danger-200: #f09898; + --c--theme--colors--danger-300: #f09898; + --c--theme--colors--danger-400: #ed8585; + --c--theme--colors--danger-500: #e96666; + --c--theme--colors--danger-600: #d66; + --c--theme--colors--danger-700: #c36666; + --c--theme--colors--danger-800: #ae6666; + --c--theme--colors--danger-900: #9d6666; +} + +.cunningham-theme--dsfr { + --c--theme--colors--secondary-icon: #c9191e; + --c--theme--logo--src: /assets/logo-gouv.svg; + --c--theme--logo--widthHeader: 110px; + --c--theme--logo--widthFooter: 220px; + --c--theme--logo--alt: gouvernement logo; + --c--components--la-gaufre: true; + --c--components--home-proconnect: true; + --c--components--beta: true; + --c--components--favicon--ico: /assets/favicon-dsfr.ico; + --c--components--favicon--png-light: /assets/favicon-dsfr.png; + --c--components--favicon--png-dark: /assets/favicon-dark-dsfr.png; +} + +.cunningham-theme--generic { + --c--theme--colors--primary-action: #206ebd; + --c--theme--colors--primary-focus: #1e64bf; + --c--theme--colors--primary-text: #2e2c28; + --c--theme--colors--primary-050: #f8f8f7; + --c--theme--colors--primary-100: #f0efec; + --c--theme--colors--primary-150: #f4f4fd; + --c--theme--colors--primary-200: #e8e7e4; + --c--theme--colors--primary-300: #cfcdc9; + --c--theme--colors--primary-400: #979592; + --c--theme--colors--primary-500: #82807d; + --c--theme--colors--primary-600: #3f3d39; + --c--theme--colors--primary-700: #2e2c28; + --c--theme--colors--primary-800: #302e29; + --c--theme--colors--primary-900: #282622; + --c--theme--colors--primary-950: #201f1c; + --c--theme--colors--secondary-text: #fff; + --c--theme--colors--secondary-50: #f4f7fa; + --c--theme--colors--secondary-100: #d7e3ee; + --c--theme--colors--secondary-200: #b8cce1; + --c--theme--colors--secondary-300: #99b4d3; + --c--theme--colors--secondary-400: #7595be; + --c--theme--colors--secondary-500: #5874a0; + --c--theme--colors--secondary-600: #3a5383; + --c--theme--colors--secondary-700: #1e3462; + --c--theme--colors--secondary-800: #091b41; + --c--theme--colors--secondary-900: #08183b; + --c--theme--colors--secondary-950: #071636; + --c--theme--colors--greyscale-text: #3c3b38; + --c--theme--colors--greyscale-000: #fff; + --c--theme--colors--greyscale-050: #f8f7f7; + --c--theme--colors--greyscale-100: #f3f3f2; + --c--theme--colors--greyscale-200: #ecebea; + --c--theme--colors--greyscale-250: #e4e3e2; + --c--theme--colors--greyscale-300: #d3d2cf; + --c--theme--colors--greyscale-350: #eee; + --c--theme--colors--greyscale-400: #96948e; + --c--theme--colors--greyscale-500: #817e77; + --c--theme--colors--greyscale-600: #6a6862; + --c--theme--colors--greyscale-700: #3c3b38; + --c--theme--colors--greyscale-750: #383632; + --c--theme--colors--greyscale-800: #2d2b27; + --c--theme--colors--greyscale-900: #262522; + --c--theme--colors--greyscale-950: #201f1c; + --c--theme--colors--greyscale-1000: #181714; + --c--theme--colors--success-text: #234935; + --c--theme--colors--success-50: #f3fbf5; + --c--theme--colors--success-100: #e4f7ea; + --c--theme--colors--success-200: #caeed4; + --c--theme--colors--success-300: #a0e0b5; + --c--theme--colors--success-400: #6cc88c; + --c--theme--colors--success-500: #6cc88c; + --c--theme--colors--success-600: #358d5c; + --c--theme--colors--success-700: #2d704b; + --c--theme--colors--success-800: #28583f; + --c--theme--colors--success-900: #234935; + --c--theme--colors--success-950: #0f281b; + --c--theme--colors--info-text: #212445; + --c--theme--colors--info-50: #f2f6fb; + --c--theme--colors--info-100: #e2e9f5; + --c--theme--colors--info-200: #ccd8ee; + --c--theme--colors--info-300: #a9c0e3; + --c--theme--colors--info-400: #809dd4; + --c--theme--colors--info-500: #617bc7; + --c--theme--colors--info-600: #4a5cbf; + --c--theme--colors--info-700: #3e49b2; + --c--theme--colors--info-800: #353c8f; + --c--theme--colors--info-900: #303771; + --c--theme--colors--info-950: #212445; + --c--theme--colors--warning-text: #d97c3a; + --c--theme--colors--warning-50: #fdf7f1; + --c--theme--colors--warning-100: #fbeddc; + --c--theme--colors--warning-200: #f5d9b9; + --c--theme--colors--warning-300: #edbe8c; + --c--theme--colors--warning-400: #e2985c; + --c--theme--colors--warning-500: #d97c3a; + --c--theme--colors--warning-600: #c96330; + --c--theme--colors--warning-700: #a34b32; + --c--theme--colors--warning-800: #813b2c; + --c--theme--colors--warning-900: #693327; + --c--theme--colors--warning-950: #381713; + --c--theme--colors--danger-action: #c0182a; + --c--theme--colors--danger-text: #fff; + --c--theme--colors--danger-050: #fdf5f4; + --c--theme--colors--danger-100: #fbebe8; + --c--theme--colors--danger-200: #f9e0dc; + --c--theme--colors--danger-300: #f3c3bd; + --c--theme--colors--danger-400: #e26552; + --c--theme--colors--danger-500: #c91f00; + --c--theme--colors--danger-600: #a71901; + --c--theme--colors--danger-700: #562c2b; + --c--theme--colors--danger-800: #392425; + --c--theme--colors--danger-900: #311f20; + --c--theme--colors--danger-950: #2a191a; + --c--theme--colors--blue-400: #8baecc; + --c--theme--colors--blue-500: #567aa2; + --c--theme--colors--blue-600: #455784; + --c--theme--colors--brown-400: #e4c090; + --c--theme--colors--brown-500: #ba9977; + --c--theme--colors--brown-600: #735c45; + --c--theme--colors--cyan-400: #5cbec9; + --c--theme--colors--cyan-500: #43a1b3; + --c--theme--colors--cyan-600: #39809b; + --c--theme--colors--gold-400: #ecbf50; + --c--theme--colors--gold-500: #dfa038; + --c--theme--colors--gold-600: #c17b31; + --c--theme--colors--green-400: #5dbd9a; + --c--theme--colors--green-500: #3aa183; + --c--theme--colors--green-600: #2a816d; + --c--theme--colors--olive-400: #afd662; + --c--theme--colors--olive-500: #90bb4b; + --c--theme--colors--olive-600: #6e9441; + --c--theme--colors--orange-400: #e2985c; + --c--theme--colors--orange-500: #d97c3a; + --c--theme--colors--orange-600: #c96330; + --c--theme--colors--pink-400: #be8fc8; + --c--theme--colors--pink-500: #a563b1; + --c--theme--colors--pink-600: #8b44a5; + --c--theme--colors--purple-400: #be8fc8; + --c--theme--colors--purple-500: #a563b1; + --c--theme--colors--purple-600: #8b44a5; + --c--theme--colors--yellow-400: #edc947; + --c--theme--colors--yellow-500: #dbb13a; + --c--theme--colors--yellow-600: #b88a34; + --c--theme--font--families--base: inter, roboto flex variable, sans-serif; + --c--theme--font--families--accent: inter, roboto flex variable, sans-serif; + --c--components--button--primary--background--color-hover: var( + --c--theme--colors--primary-focus + ); + --c--components--button--primary--background--color-active: var( + --c--theme--colors--primary-focus + ); + --c--components--button--primary--background--color-focus: var( + --c--theme--colors--primary-focus + ); + --c--components--image-system-filter: saturate(0.2); +} + +.clr-secondary-text { + color: var(--c--theme--colors--secondary-text); +} + +.clr-secondary-100 { + color: var(--c--theme--colors--secondary-100); +} + +.clr-secondary-200 { + color: var(--c--theme--colors--secondary-200); +} + +.clr-secondary-300 { + color: var(--c--theme--colors--secondary-300); +} + +.clr-secondary-400 { + color: var(--c--theme--colors--secondary-400); +} + +.clr-secondary-500 { + color: var(--c--theme--colors--secondary-500); +} + +.clr-secondary-600 { + color: var(--c--theme--colors--secondary-600); +} + +.clr-secondary-700 { + color: var(--c--theme--colors--secondary-700); +} + +.clr-secondary-800 { + color: var(--c--theme--colors--secondary-800); +} + +.clr-secondary-900 { + color: var(--c--theme--colors--secondary-900); +} + +.clr-info-text { + color: var(--c--theme--colors--info-text); +} + +.clr-info-100 { + color: var(--c--theme--colors--info-100); +} + +.clr-info-200 { + color: var(--c--theme--colors--info-200); +} + +.clr-info-300 { + color: var(--c--theme--colors--info-300); +} + +.clr-info-400 { + color: var(--c--theme--colors--info-400); +} + +.clr-info-500 { + color: var(--c--theme--colors--info-500); +} + +.clr-info-600 { + color: var(--c--theme--colors--info-600); +} + +.clr-info-700 { + color: var(--c--theme--colors--info-700); +} + +.clr-info-800 { + color: var(--c--theme--colors--info-800); +} + +.clr-info-900 { + color: var(--c--theme--colors--info-900); +} + +.clr-greyscale-100 { + color: var(--c--theme--colors--greyscale-100); +} + +.clr-greyscale-200 { + color: var(--c--theme--colors--greyscale-200); +} + +.clr-greyscale-300 { + color: var(--c--theme--colors--greyscale-300); +} + +.clr-greyscale-400 { + color: var(--c--theme--colors--greyscale-400); +} + +.clr-greyscale-500 { + color: var(--c--theme--colors--greyscale-500); +} + +.clr-greyscale-600 { + color: var(--c--theme--colors--greyscale-600); +} + +.clr-greyscale-700 { + color: var(--c--theme--colors--greyscale-700); +} + +.clr-greyscale-800 { + color: var(--c--theme--colors--greyscale-800); +} + +.clr-greyscale-900 { + color: var(--c--theme--colors--greyscale-900); +} + +.clr-greyscale-000 { + color: var(--c--theme--colors--greyscale-000); +} + +.clr-primary-100 { + color: var(--c--theme--colors--primary-100); +} + +.clr-primary-200 { + color: var(--c--theme--colors--primary-200); +} + +.clr-primary-300 { + color: var(--c--theme--colors--primary-300); +} + +.clr-primary-400 { + color: var(--c--theme--colors--primary-400); +} + +.clr-primary-500 { + color: var(--c--theme--colors--primary-500); +} + +.clr-primary-600 { + color: var(--c--theme--colors--primary-600); +} + +.clr-primary-700 { + color: var(--c--theme--colors--primary-700); +} + +.clr-primary-800 { + color: var(--c--theme--colors--primary-800); +} + +.clr-primary-900 { + color: var(--c--theme--colors--primary-900); +} + +.clr-success-100 { + color: var(--c--theme--colors--success-100); +} + +.clr-success-200 { + color: var(--c--theme--colors--success-200); +} + +.clr-success-300 { + color: var(--c--theme--colors--success-300); +} + +.clr-success-400 { + color: var(--c--theme--colors--success-400); +} + +.clr-success-500 { + color: var(--c--theme--colors--success-500); +} + +.clr-success-600 { + color: var(--c--theme--colors--success-600); +} + +.clr-success-700 { + color: var(--c--theme--colors--success-700); +} + +.clr-success-800 { + color: var(--c--theme--colors--success-800); +} + +.clr-success-900 { + color: var(--c--theme--colors--success-900); +} + +.clr-warning-100 { + color: var(--c--theme--colors--warning-100); +} + +.clr-warning-200 { + color: var(--c--theme--colors--warning-200); +} + +.clr-warning-300 { + color: var(--c--theme--colors--warning-300); +} + +.clr-warning-400 { + color: var(--c--theme--colors--warning-400); +} + +.clr-warning-500 { + color: var(--c--theme--colors--warning-500); +} + +.clr-warning-600 { + color: var(--c--theme--colors--warning-600); +} + +.clr-warning-700 { + color: var(--c--theme--colors--warning-700); +} + +.clr-warning-800 { + color: var(--c--theme--colors--warning-800); +} + +.clr-warning-900 { + color: var(--c--theme--colors--warning-900); +} + +.clr-danger-100 { + color: var(--c--theme--colors--danger-100); +} + +.clr-danger-200 { + color: var(--c--theme--colors--danger-200); +} + +.clr-danger-300 { + color: var(--c--theme--colors--danger-300); +} + +.clr-danger-400 { + color: var(--c--theme--colors--danger-400); +} + +.clr-danger-500 { + color: var(--c--theme--colors--danger-500); +} + +.clr-danger-600 { + color: var(--c--theme--colors--danger-600); +} + +.clr-danger-700 { + color: var(--c--theme--colors--danger-700); +} + +.clr-danger-800 { + color: var(--c--theme--colors--danger-800); +} + +.clr-danger-900 { + color: var(--c--theme--colors--danger-900); +} + +.clr-primary-text { + color: var(--c--theme--colors--primary-text); +} + +.clr-success-text { + color: var(--c--theme--colors--success-text); +} + +.clr-warning-text { + color: var(--c--theme--colors--warning-text); +} + +.clr-danger-text { + color: var(--c--theme--colors--danger-text); +} + +.clr-primary-050 { + color: var(--c--theme--colors--primary-050); +} + +.clr-primary-150 { + color: var(--c--theme--colors--primary-150); +} + +.clr-greyscale-text { + color: var(--c--theme--colors--greyscale-text); +} + +.clr-greyscale-050 { + color: var(--c--theme--colors--greyscale-050); +} + +.clr-greyscale-250 { + color: var(--c--theme--colors--greyscale-250); +} + +.clr-greyscale-350 { + color: var(--c--theme--colors--greyscale-350); +} + +.clr-greyscale-750 { + color: var(--c--theme--colors--greyscale-750); +} + +.clr-greyscale-950 { + color: var(--c--theme--colors--greyscale-950); +} + +.clr-greyscale-1000 { + color: var(--c--theme--colors--greyscale-1000); +} + +.clr-danger-050 { + color: var(--c--theme--colors--danger-050); +} + +.clr-blue-500 { + color: var(--c--theme--colors--blue-500); +} + +.clr-brown-500 { + color: var(--c--theme--colors--brown-500); +} + +.clr-cyan-500 { + color: var(--c--theme--colors--cyan-500); +} + +.clr-gold-500 { + color: var(--c--theme--colors--gold-500); +} + +.clr-green-500 { + color: var(--c--theme--colors--green-500); +} + +.clr-olive-500 { + color: var(--c--theme--colors--olive-500); +} + +.clr-orange-500 { + color: var(--c--theme--colors--orange-500); +} + +.clr-purple-500 { + color: var(--c--theme--colors--purple-500); +} + +.clr-red-500 { + color: var(--c--theme--colors--red-500); +} + +.clr-yellow-500 { + color: var(--c--theme--colors--yellow-500); +} + +.clr-rose-500 { + color: var(--c--theme--colors--rose-500); +} + +.clr-primary-action { + color: var(--c--theme--colors--primary-action); +} + +.clr-primary-bg { + color: var(--c--theme--colors--primary-bg); +} + +.clr-primary-focus { + color: var(--c--theme--colors--primary-focus); +} + +.clr-secondary-icon { + color: var(--c--theme--colors--secondary-icon); +} + +.clr-blue-400 { + color: var(--c--theme--colors--blue-400); +} + +.clr-blue-600 { + color: var(--c--theme--colors--blue-600); +} + +.clr-brown-400 { + color: var(--c--theme--colors--brown-400); +} + +.clr-brown-600 { + color: var(--c--theme--colors--brown-600); +} + +.clr-cyan-400 { + color: var(--c--theme--colors--cyan-400); +} + +.clr-cyan-600 { + color: var(--c--theme--colors--cyan-600); +} + +.clr-gold-400 { + color: var(--c--theme--colors--gold-400); +} + +.clr-gold-600 { + color: var(--c--theme--colors--gold-600); +} + +.clr-green-400 { + color: var(--c--theme--colors--green-400); +} + +.clr-green-600 { + color: var(--c--theme--colors--green-600); +} + +.clr-olive-400 { + color: var(--c--theme--colors--olive-400); +} + +.clr-olive-600 { + color: var(--c--theme--colors--olive-600); +} + +.clr-orange-400 { + color: var(--c--theme--colors--orange-400); +} + +.clr-orange-600 { + color: var(--c--theme--colors--orange-600); +} + +.clr-pink-400 { + color: var(--c--theme--colors--pink-400); +} + +.clr-pink-500 { + color: var(--c--theme--colors--pink-500); +} + +.clr-pink-600 { + color: var(--c--theme--colors--pink-600); +} + +.clr-purple-400 { + color: var(--c--theme--colors--purple-400); +} + +.clr-purple-600 { + color: var(--c--theme--colors--purple-600); +} + +.clr-yellow-400 { + color: var(--c--theme--colors--yellow-400); +} + +.clr-yellow-600 { + color: var(--c--theme--colors--yellow-600); +} + +.bg-secondary-text { + background-color: var(--c--theme--colors--secondary-text); +} + +.bg-secondary-100 { + background-color: var(--c--theme--colors--secondary-100); +} + +.bg-secondary-200 { + background-color: var(--c--theme--colors--secondary-200); +} + +.bg-secondary-300 { + background-color: var(--c--theme--colors--secondary-300); +} + +.bg-secondary-400 { + background-color: var(--c--theme--colors--secondary-400); +} + +.bg-secondary-500 { + background-color: var(--c--theme--colors--secondary-500); +} + +.bg-secondary-600 { + background-color: var(--c--theme--colors--secondary-600); +} + +.bg-secondary-700 { + background-color: var(--c--theme--colors--secondary-700); +} + +.bg-secondary-800 { + background-color: var(--c--theme--colors--secondary-800); +} + +.bg-secondary-900 { + background-color: var(--c--theme--colors--secondary-900); +} + +.bg-info-text { + background-color: var(--c--theme--colors--info-text); +} + +.bg-info-100 { + background-color: var(--c--theme--colors--info-100); +} + +.bg-info-200 { + background-color: var(--c--theme--colors--info-200); +} + +.bg-info-300 { + background-color: var(--c--theme--colors--info-300); +} + +.bg-info-400 { + background-color: var(--c--theme--colors--info-400); +} + +.bg-info-500 { + background-color: var(--c--theme--colors--info-500); +} + +.bg-info-600 { + background-color: var(--c--theme--colors--info-600); +} + +.bg-info-700 { + background-color: var(--c--theme--colors--info-700); +} + +.bg-info-800 { + background-color: var(--c--theme--colors--info-800); +} + +.bg-info-900 { + background-color: var(--c--theme--colors--info-900); +} + +.bg-greyscale-100 { + background-color: var(--c--theme--colors--greyscale-100); +} + +.bg-greyscale-200 { + background-color: var(--c--theme--colors--greyscale-200); +} + +.bg-greyscale-300 { + background-color: var(--c--theme--colors--greyscale-300); +} + +.bg-greyscale-400 { + background-color: var(--c--theme--colors--greyscale-400); +} + +.bg-greyscale-500 { + background-color: var(--c--theme--colors--greyscale-500); +} + +.bg-greyscale-600 { + background-color: var(--c--theme--colors--greyscale-600); +} + +.bg-greyscale-700 { + background-color: var(--c--theme--colors--greyscale-700); +} + +.bg-greyscale-800 { + background-color: var(--c--theme--colors--greyscale-800); +} + +.bg-greyscale-900 { + background-color: var(--c--theme--colors--greyscale-900); +} + +.bg-greyscale-000 { + background-color: var(--c--theme--colors--greyscale-000); +} + +.bg-primary-100 { + background-color: var(--c--theme--colors--primary-100); +} + +.bg-primary-200 { + background-color: var(--c--theme--colors--primary-200); +} + +.bg-primary-300 { + background-color: var(--c--theme--colors--primary-300); +} + +.bg-primary-400 { + background-color: var(--c--theme--colors--primary-400); +} + +.bg-primary-500 { + background-color: var(--c--theme--colors--primary-500); +} + +.bg-primary-600 { + background-color: var(--c--theme--colors--primary-600); +} + +.bg-primary-700 { + background-color: var(--c--theme--colors--primary-700); +} + +.bg-primary-800 { + background-color: var(--c--theme--colors--primary-800); +} + +.bg-primary-900 { + background-color: var(--c--theme--colors--primary-900); +} + +.bg-success-100 { + background-color: var(--c--theme--colors--success-100); +} + +.bg-success-200 { + background-color: var(--c--theme--colors--success-200); +} + +.bg-success-300 { + background-color: var(--c--theme--colors--success-300); +} + +.bg-success-400 { + background-color: var(--c--theme--colors--success-400); +} + +.bg-success-500 { + background-color: var(--c--theme--colors--success-500); +} + +.bg-success-600 { + background-color: var(--c--theme--colors--success-600); +} + +.bg-success-700 { + background-color: var(--c--theme--colors--success-700); +} + +.bg-success-800 { + background-color: var(--c--theme--colors--success-800); +} + +.bg-success-900 { + background-color: var(--c--theme--colors--success-900); +} + +.bg-warning-100 { + background-color: var(--c--theme--colors--warning-100); +} + +.bg-warning-200 { + background-color: var(--c--theme--colors--warning-200); +} + +.bg-warning-300 { + background-color: var(--c--theme--colors--warning-300); +} + +.bg-warning-400 { + background-color: var(--c--theme--colors--warning-400); +} + +.bg-warning-500 { + background-color: var(--c--theme--colors--warning-500); +} + +.bg-warning-600 { + background-color: var(--c--theme--colors--warning-600); +} + +.bg-warning-700 { + background-color: var(--c--theme--colors--warning-700); +} + +.bg-warning-800 { + background-color: var(--c--theme--colors--warning-800); +} + +.bg-warning-900 { + background-color: var(--c--theme--colors--warning-900); +} + +.bg-danger-100 { + background-color: var(--c--theme--colors--danger-100); +} + +.bg-danger-200 { + background-color: var(--c--theme--colors--danger-200); +} + +.bg-danger-300 { + background-color: var(--c--theme--colors--danger-300); +} + +.bg-danger-400 { + background-color: var(--c--theme--colors--danger-400); +} + +.bg-danger-500 { + background-color: var(--c--theme--colors--danger-500); +} + +.bg-danger-600 { + background-color: var(--c--theme--colors--danger-600); +} + +.bg-danger-700 { + background-color: var(--c--theme--colors--danger-700); +} + +.bg-danger-800 { + background-color: var(--c--theme--colors--danger-800); +} + +.bg-danger-900 { + background-color: var(--c--theme--colors--danger-900); +} + +.bg-primary-text { + background-color: var(--c--theme--colors--primary-text); +} + +.bg-success-text { + background-color: var(--c--theme--colors--success-text); +} + +.bg-warning-text { + background-color: var(--c--theme--colors--warning-text); +} + +.bg-danger-text { + background-color: var(--c--theme--colors--danger-text); +} + +.bg-primary-050 { + background-color: var(--c--theme--colors--primary-050); +} + +.bg-primary-150 { + background-color: var(--c--theme--colors--primary-150); +} + +.bg-greyscale-text { + background-color: var(--c--theme--colors--greyscale-text); +} + +.bg-greyscale-050 { + background-color: var(--c--theme--colors--greyscale-050); +} + +.bg-greyscale-250 { + background-color: var(--c--theme--colors--greyscale-250); +} + +.bg-greyscale-350 { + background-color: var(--c--theme--colors--greyscale-350); +} + +.bg-greyscale-750 { + background-color: var(--c--theme--colors--greyscale-750); +} + +.bg-greyscale-950 { + background-color: var(--c--theme--colors--greyscale-950); +} + +.bg-greyscale-1000 { + background-color: var(--c--theme--colors--greyscale-1000); +} + +.bg-danger-050 { + background-color: var(--c--theme--colors--danger-050); +} + +.bg-blue-500 { + background-color: var(--c--theme--colors--blue-500); +} + +.bg-brown-500 { + background-color: var(--c--theme--colors--brown-500); +} + +.bg-cyan-500 { + background-color: var(--c--theme--colors--cyan-500); +} + +.bg-gold-500 { + background-color: var(--c--theme--colors--gold-500); +} + +.bg-green-500 { + background-color: var(--c--theme--colors--green-500); +} + +.bg-olive-500 { + background-color: var(--c--theme--colors--olive-500); +} + +.bg-orange-500 { + background-color: var(--c--theme--colors--orange-500); +} + +.bg-purple-500 { + background-color: var(--c--theme--colors--purple-500); +} + +.bg-red-500 { + background-color: var(--c--theme--colors--red-500); +} + +.bg-yellow-500 { + background-color: var(--c--theme--colors--yellow-500); +} + +.bg-rose-500 { + background-color: var(--c--theme--colors--rose-500); +} + +.bg-primary-action { + background-color: var(--c--theme--colors--primary-action); +} + +.bg-primary-bg { + background-color: var(--c--theme--colors--primary-bg); +} + +.bg-primary-focus { + background-color: var(--c--theme--colors--primary-focus); +} + +.bg-secondary-icon { + background-color: var(--c--theme--colors--secondary-icon); +} + +.bg-blue-400 { + background-color: var(--c--theme--colors--blue-400); +} + +.bg-blue-600 { + background-color: var(--c--theme--colors--blue-600); +} + +.bg-brown-400 { + background-color: var(--c--theme--colors--brown-400); +} + +.bg-brown-600 { + background-color: var(--c--theme--colors--brown-600); +} + +.bg-cyan-400 { + background-color: var(--c--theme--colors--cyan-400); +} + +.bg-cyan-600 { + background-color: var(--c--theme--colors--cyan-600); +} + +.bg-gold-400 { + background-color: var(--c--theme--colors--gold-400); +} + +.bg-gold-600 { + background-color: var(--c--theme--colors--gold-600); +} + +.bg-green-400 { + background-color: var(--c--theme--colors--green-400); +} + +.bg-green-600 { + background-color: var(--c--theme--colors--green-600); +} + +.bg-olive-400 { + background-color: var(--c--theme--colors--olive-400); +} + +.bg-olive-600 { + background-color: var(--c--theme--colors--olive-600); +} + +.bg-orange-400 { + background-color: var(--c--theme--colors--orange-400); +} + +.bg-orange-600 { + background-color: var(--c--theme--colors--orange-600); +} + +.bg-pink-400 { + background-color: var(--c--theme--colors--pink-400); +} + +.bg-pink-500 { + background-color: var(--c--theme--colors--pink-500); +} + +.bg-pink-600 { + background-color: var(--c--theme--colors--pink-600); +} + +.bg-purple-400 { + background-color: var(--c--theme--colors--purple-400); +} + +.bg-purple-600 { + background-color: var(--c--theme--colors--purple-600); +} + +.bg-yellow-400 { + background-color: var(--c--theme--colors--yellow-400); +} + +.bg-yellow-600 { + background-color: var(--c--theme--colors--yellow-600); +} + +.fw-thin { + font-weight: var(--c--theme--font--weights--thin); +} + +.fw-light { + font-weight: var(--c--theme--font--weights--light); +} + +.fw-regular { + font-weight: var(--c--theme--font--weights--regular); +} + +.fw-medium { + font-weight: var(--c--theme--font--weights--medium); +} + +.fw-bold { + font-weight: var(--c--theme--font--weights--bold); +} + +.fw-extrabold { + font-weight: var(--c--theme--font--weights--extrabold); +} + +.fw-black { + font-weight: var(--c--theme--font--weights--black); +} + +.fs-h1 { + font-size: var(--c--theme--font--sizes--h1); + letter-spacing: var(--c--theme--font--letterspacings--h1); +} + +.fs-h2 { + font-size: var(--c--theme--font--sizes--h2); + letter-spacing: var(--c--theme--font--letterspacings--h2); +} + +.fs-h3 { + font-size: var(--c--theme--font--sizes--h3); + letter-spacing: var(--c--theme--font--letterspacings--h3); +} + +.fs-h4 { + font-size: var(--c--theme--font--sizes--h4); + letter-spacing: var(--c--theme--font--letterspacings--h4); +} + +.fs-h5 { + font-size: var(--c--theme--font--sizes--h5); + letter-spacing: var(--c--theme--font--letterspacings--h5); +} + +.fs-h6 { + font-size: var(--c--theme--font--sizes--h6); + letter-spacing: var(--c--theme--font--letterspacings--h6); +} + +.fs-l { + font-size: var(--c--theme--font--sizes--l); + letter-spacing: var(--c--theme--font--letterspacings--l); +} + +.fs-m { + font-size: var(--c--theme--font--sizes--m); + letter-spacing: var(--c--theme--font--letterspacings--m); +} + +.fs-s { + font-size: var(--c--theme--font--sizes--s); + letter-spacing: var(--c--theme--font--letterspacings--s); +} + +.fs-xs { + font-size: var(--c--theme--font--sizes--xs); + letter-spacing: var(--c--theme--font--letterspacings--xs); +} + +.fs-sm { + font-size: var(--c--theme--font--sizes--sm); + letter-spacing: var(--c--theme--font--letterspacings--sm); +} + +.fs-md { + font-size: var(--c--theme--font--sizes--md); + letter-spacing: var(--c--theme--font--letterspacings--md); +} + +.fs-lg { + font-size: var(--c--theme--font--sizes--lg); + letter-spacing: var(--c--theme--font--letterspacings--lg); +} + +.fs-ml { + font-size: var(--c--theme--font--sizes--ml); + letter-spacing: var(--c--theme--font--letterspacings--ml); +} + +.fs-xl { + font-size: var(--c--theme--font--sizes--xl); + letter-spacing: var(--c--theme--font--letterspacings--xl); +} + +.fs-t { + font-size: var(--c--theme--font--sizes--t); + letter-spacing: var(--c--theme--font--letterspacings--t); +} + +.fs-xl-alt { + font-size: var(--c--theme--font--sizes--xl-alt); + letter-spacing: var(--c--theme--font--letterspacings--xl-alt); +} + +.fs-lg-alt { + font-size: var(--c--theme--font--sizes--lg-alt); + letter-spacing: var(--c--theme--font--letterspacings--lg-alt); +} + +.fs-md-alt { + font-size: var(--c--theme--font--sizes--md-alt); + letter-spacing: var(--c--theme--font--letterspacings--md-alt); +} + +.fs-sm-alt { + font-size: var(--c--theme--font--sizes--sm-alt); + letter-spacing: var(--c--theme--font--letterspacings--sm-alt); +} + +.fs-xs-alt { + font-size: var(--c--theme--font--sizes--xs-alt); + letter-spacing: var(--c--theme--font--letterspacings--xs-alt); +} + +.f-base { + font-family: var(--c--theme--font--families--base); +} + +.f-accent { + font-family: var(--c--theme--font--families--accent); +} + +.m-0 { + margin: var(--c--theme--spacings--0); +} + +.mb-0 { + margin-bottom: var(--c--theme--spacings--0); +} + +.mt-0 { + margin-top: var(--c--theme--spacings--0); +} + +.ml-0 { + margin-left: var(--c--theme--spacings--0); +} + +.mr-0 { + margin-right: var(--c--theme--spacings--0); +} + +.m-xl { + margin: var(--c--theme--spacings--xl); +} + +.mb-xl { + margin-bottom: var(--c--theme--spacings--xl); +} + +.mt-xl { + margin-top: var(--c--theme--spacings--xl); +} + +.ml-xl { + margin-left: var(--c--theme--spacings--xl); +} + +.mr-xl { + margin-right: var(--c--theme--spacings--xl); +} + +.m-l { + margin: var(--c--theme--spacings--l); +} + +.mb-l { + margin-bottom: var(--c--theme--spacings--l); +} + +.mt-l { + margin-top: var(--c--theme--spacings--l); +} + +.ml-l { + margin-left: var(--c--theme--spacings--l); +} + +.mr-l { + margin-right: var(--c--theme--spacings--l); +} + +.m-b { + margin: var(--c--theme--spacings--b); +} + +.mb-b { + margin-bottom: var(--c--theme--spacings--b); +} + +.mt-b { + margin-top: var(--c--theme--spacings--b); +} + +.ml-b { + margin-left: var(--c--theme--spacings--b); +} + +.mr-b { + margin-right: var(--c--theme--spacings--b); +} + +.m-s { + margin: var(--c--theme--spacings--s); +} + +.mb-s { + margin-bottom: var(--c--theme--spacings--s); +} + +.mt-s { + margin-top: var(--c--theme--spacings--s); +} + +.ml-s { + margin-left: var(--c--theme--spacings--s); +} + +.mr-s { + margin-right: var(--c--theme--spacings--s); +} + +.m-t { + margin: var(--c--theme--spacings--t); +} + +.mb-t { + margin-bottom: var(--c--theme--spacings--t); +} + +.mt-t { + margin-top: var(--c--theme--spacings--t); +} + +.ml-t { + margin-left: var(--c--theme--spacings--t); +} + +.mr-t { + margin-right: var(--c--theme--spacings--t); +} + +.m-st { + margin: var(--c--theme--spacings--st); +} + +.mb-st { + margin-bottom: var(--c--theme--spacings--st); +} + +.mt-st { + margin-top: var(--c--theme--spacings--st); +} + +.ml-st { + margin-left: var(--c--theme--spacings--st); +} + +.mr-st { + margin-right: var(--c--theme--spacings--st); +} + +.m-none { + margin: var(--c--theme--spacings--none); +} + +.mb-none { + margin-bottom: var(--c--theme--spacings--none); +} + +.mt-none { + margin-top: var(--c--theme--spacings--none); +} + +.ml-none { + margin-left: var(--c--theme--spacings--none); +} + +.mr-none { + margin-right: var(--c--theme--spacings--none); +} + +.m-auto { + margin: var(--c--theme--spacings--auto); +} + +.mb-auto { + margin-bottom: var(--c--theme--spacings--auto); +} + +.mt-auto { + margin-top: var(--c--theme--spacings--auto); +} + +.ml-auto { + margin-left: var(--c--theme--spacings--auto); +} + +.mr-auto { + margin-right: var(--c--theme--spacings--auto); +} + +.m-bx { + margin: var(--c--theme--spacings--bx); +} + +.mb-bx { + margin-bottom: var(--c--theme--spacings--bx); +} + +.mt-bx { + margin-top: var(--c--theme--spacings--bx); +} + +.ml-bx { + margin-left: var(--c--theme--spacings--bx); +} + +.mr-bx { + margin-right: var(--c--theme--spacings--bx); +} + +.m-full { + margin: var(--c--theme--spacings--full); +} + +.mb-full { + margin-bottom: var(--c--theme--spacings--full); +} + +.mt-full { + margin-top: var(--c--theme--spacings--full); +} + +.ml-full { + margin-left: var(--c--theme--spacings--full); +} + +.mr-full { + margin-right: var(--c--theme--spacings--full); +} + +.m-4xs { + margin: var(--c--theme--spacings--4xs); +} + +.mb-4xs { + margin-bottom: var(--c--theme--spacings--4xs); +} + +.mt-4xs { + margin-top: var(--c--theme--spacings--4xs); +} + +.ml-4xs { + margin-left: var(--c--theme--spacings--4xs); +} + +.mr-4xs { + margin-right: var(--c--theme--spacings--4xs); +} + +.m-3xs { + margin: var(--c--theme--spacings--3xs); +} + +.mb-3xs { + margin-bottom: var(--c--theme--spacings--3xs); +} + +.mt-3xs { + margin-top: var(--c--theme--spacings--3xs); +} + +.ml-3xs { + margin-left: var(--c--theme--spacings--3xs); +} + +.mr-3xs { + margin-right: var(--c--theme--spacings--3xs); +} + +.m-2xs { + margin: var(--c--theme--spacings--2xs); +} + +.mb-2xs { + margin-bottom: var(--c--theme--spacings--2xs); +} + +.mt-2xs { + margin-top: var(--c--theme--spacings--2xs); +} + +.ml-2xs { + margin-left: var(--c--theme--spacings--2xs); +} + +.mr-2xs { + margin-right: var(--c--theme--spacings--2xs); +} + +.m-xs { + margin: var(--c--theme--spacings--xs); +} + +.mb-xs { + margin-bottom: var(--c--theme--spacings--xs); +} + +.mt-xs { + margin-top: var(--c--theme--spacings--xs); +} + +.ml-xs { + margin-left: var(--c--theme--spacings--xs); +} + +.mr-xs { + margin-right: var(--c--theme--spacings--xs); +} + +.m-sm { + margin: var(--c--theme--spacings--sm); +} + +.mb-sm { + margin-bottom: var(--c--theme--spacings--sm); +} + +.mt-sm { + margin-top: var(--c--theme--spacings--sm); +} + +.ml-sm { + margin-left: var(--c--theme--spacings--sm); +} + +.mr-sm { + margin-right: var(--c--theme--spacings--sm); +} + +.m-base { + margin: var(--c--theme--spacings--base); +} + +.mb-base { + margin-bottom: var(--c--theme--spacings--base); +} + +.mt-base { + margin-top: var(--c--theme--spacings--base); +} + +.ml-base { + margin-left: var(--c--theme--spacings--base); +} + +.mr-base { + margin-right: var(--c--theme--spacings--base); +} + +.m-md { + margin: var(--c--theme--spacings--md); +} + +.mb-md { + margin-bottom: var(--c--theme--spacings--md); +} + +.mt-md { + margin-top: var(--c--theme--spacings--md); +} + +.ml-md { + margin-left: var(--c--theme--spacings--md); +} + +.mr-md { + margin-right: var(--c--theme--spacings--md); +} + +.m-lg { + margin: var(--c--theme--spacings--lg); +} + +.mb-lg { + margin-bottom: var(--c--theme--spacings--lg); +} + +.mt-lg { + margin-top: var(--c--theme--spacings--lg); +} + +.ml-lg { + margin-left: var(--c--theme--spacings--lg); +} + +.mr-lg { + margin-right: var(--c--theme--spacings--lg); +} + +.m-xxl { + margin: var(--c--theme--spacings--xxl); +} + +.mb-xxl { + margin-bottom: var(--c--theme--spacings--xxl); +} + +.mt-xxl { + margin-top: var(--c--theme--spacings--xxl); +} + +.ml-xxl { + margin-left: var(--c--theme--spacings--xxl); +} + +.mr-xxl { + margin-right: var(--c--theme--spacings--xxl); +} + +.m-xxxl { + margin: var(--c--theme--spacings--xxxl); +} + +.mb-xxxl { + margin-bottom: var(--c--theme--spacings--xxxl); +} + +.mt-xxxl { + margin-top: var(--c--theme--spacings--xxxl); +} + +.ml-xxxl { + margin-left: var(--c--theme--spacings--xxxl); +} + +.mr-xxxl { + margin-right: var(--c--theme--spacings--xxxl); +} + +.m-4xl { + margin: var(--c--theme--spacings--4xl); +} + +.mb-4xl { + margin-bottom: var(--c--theme--spacings--4xl); +} + +.mt-4xl { + margin-top: var(--c--theme--spacings--4xl); +} + +.ml-4xl { + margin-left: var(--c--theme--spacings--4xl); +} + +.mr-4xl { + margin-right: var(--c--theme--spacings--4xl); +} + +.m-5xl { + margin: var(--c--theme--spacings--5xl); +} + +.mb-5xl { + margin-bottom: var(--c--theme--spacings--5xl); +} + +.mt-5xl { + margin-top: var(--c--theme--spacings--5xl); +} + +.ml-5xl { + margin-left: var(--c--theme--spacings--5xl); +} + +.mr-5xl { + margin-right: var(--c--theme--spacings--5xl); +} + +.m-6xl { + margin: var(--c--theme--spacings--6xl); +} + +.mb-6xl { + margin-bottom: var(--c--theme--spacings--6xl); +} + +.mt-6xl { + margin-top: var(--c--theme--spacings--6xl); +} + +.ml-6xl { + margin-left: var(--c--theme--spacings--6xl); +} + +.mr-6xl { + margin-right: var(--c--theme--spacings--6xl); +} + +.m-7xl { + margin: var(--c--theme--spacings--7xl); +} + +.mb-7xl { + margin-bottom: var(--c--theme--spacings--7xl); +} + +.mt-7xl { + margin-top: var(--c--theme--spacings--7xl); +} + +.ml-7xl { + margin-left: var(--c--theme--spacings--7xl); +} + +.mr-7xl { + margin-right: var(--c--theme--spacings--7xl); +} + +.p-0 { + padding: var(--c--theme--spacings--0); +} + +.pb-0 { + padding-bottom: var(--c--theme--spacings--0); +} + +.pt-0 { + padding-top: var(--c--theme--spacings--0); +} + +.pl-0 { + padding-left: var(--c--theme--spacings--0); +} + +.pr-0 { + padding-right: var(--c--theme--spacings--0); +} + +.p-xl { + padding: var(--c--theme--spacings--xl); +} + +.pb-xl { + padding-bottom: var(--c--theme--spacings--xl); +} + +.pt-xl { + padding-top: var(--c--theme--spacings--xl); +} + +.pl-xl { + padding-left: var(--c--theme--spacings--xl); +} + +.pr-xl { + padding-right: var(--c--theme--spacings--xl); +} + +.p-l { + padding: var(--c--theme--spacings--l); +} + +.pb-l { + padding-bottom: var(--c--theme--spacings--l); +} + +.pt-l { + padding-top: var(--c--theme--spacings--l); +} + +.pl-l { + padding-left: var(--c--theme--spacings--l); +} + +.pr-l { + padding-right: var(--c--theme--spacings--l); +} + +.p-b { + padding: var(--c--theme--spacings--b); +} + +.pb-b { + padding-bottom: var(--c--theme--spacings--b); +} + +.pt-b { + padding-top: var(--c--theme--spacings--b); +} + +.pl-b { + padding-left: var(--c--theme--spacings--b); +} + +.pr-b { + padding-right: var(--c--theme--spacings--b); +} + +.p-s { + padding: var(--c--theme--spacings--s); +} + +.pb-s { + padding-bottom: var(--c--theme--spacings--s); +} + +.pt-s { + padding-top: var(--c--theme--spacings--s); +} + +.pl-s { + padding-left: var(--c--theme--spacings--s); +} + +.pr-s { + padding-right: var(--c--theme--spacings--s); +} + +.p-t { + padding: var(--c--theme--spacings--t); +} + +.pb-t { + padding-bottom: var(--c--theme--spacings--t); +} + +.pt-t { + padding-top: var(--c--theme--spacings--t); +} + +.pl-t { + padding-left: var(--c--theme--spacings--t); +} + +.pr-t { + padding-right: var(--c--theme--spacings--t); +} + +.p-st { + padding: var(--c--theme--spacings--st); +} + +.pb-st { + padding-bottom: var(--c--theme--spacings--st); +} + +.pt-st { + padding-top: var(--c--theme--spacings--st); +} + +.pl-st { + padding-left: var(--c--theme--spacings--st); +} + +.pr-st { + padding-right: var(--c--theme--spacings--st); +} + +.p-none { + padding: var(--c--theme--spacings--none); +} + +.pb-none { + padding-bottom: var(--c--theme--spacings--none); +} + +.pt-none { + padding-top: var(--c--theme--spacings--none); +} + +.pl-none { + padding-left: var(--c--theme--spacings--none); +} + +.pr-none { + padding-right: var(--c--theme--spacings--none); +} + +.p-auto { + padding: var(--c--theme--spacings--auto); +} + +.pb-auto { + padding-bottom: var(--c--theme--spacings--auto); +} + +.pt-auto { + padding-top: var(--c--theme--spacings--auto); +} + +.pl-auto { + padding-left: var(--c--theme--spacings--auto); +} + +.pr-auto { + padding-right: var(--c--theme--spacings--auto); +} + +.p-bx { + padding: var(--c--theme--spacings--bx); +} + +.pb-bx { + padding-bottom: var(--c--theme--spacings--bx); +} + +.pt-bx { + padding-top: var(--c--theme--spacings--bx); +} + +.pl-bx { + padding-left: var(--c--theme--spacings--bx); +} + +.pr-bx { + padding-right: var(--c--theme--spacings--bx); +} + +.p-full { + padding: var(--c--theme--spacings--full); +} + +.pb-full { + padding-bottom: var(--c--theme--spacings--full); +} + +.pt-full { + padding-top: var(--c--theme--spacings--full); +} + +.pl-full { + padding-left: var(--c--theme--spacings--full); +} + +.pr-full { + padding-right: var(--c--theme--spacings--full); +} + +.p-4xs { + padding: var(--c--theme--spacings--4xs); +} + +.pb-4xs { + padding-bottom: var(--c--theme--spacings--4xs); +} + +.pt-4xs { + padding-top: var(--c--theme--spacings--4xs); +} + +.pl-4xs { + padding-left: var(--c--theme--spacings--4xs); +} + +.pr-4xs { + padding-right: var(--c--theme--spacings--4xs); +} + +.p-3xs { + padding: var(--c--theme--spacings--3xs); +} + +.pb-3xs { + padding-bottom: var(--c--theme--spacings--3xs); +} + +.pt-3xs { + padding-top: var(--c--theme--spacings--3xs); +} + +.pl-3xs { + padding-left: var(--c--theme--spacings--3xs); +} + +.pr-3xs { + padding-right: var(--c--theme--spacings--3xs); +} + +.p-2xs { + padding: var(--c--theme--spacings--2xs); +} + +.pb-2xs { + padding-bottom: var(--c--theme--spacings--2xs); +} + +.pt-2xs { + padding-top: var(--c--theme--spacings--2xs); +} + +.pl-2xs { + padding-left: var(--c--theme--spacings--2xs); +} + +.pr-2xs { + padding-right: var(--c--theme--spacings--2xs); +} + +.p-xs { + padding: var(--c--theme--spacings--xs); +} + +.pb-xs { + padding-bottom: var(--c--theme--spacings--xs); +} + +.pt-xs { + padding-top: var(--c--theme--spacings--xs); +} + +.pl-xs { + padding-left: var(--c--theme--spacings--xs); +} + +.pr-xs { + padding-right: var(--c--theme--spacings--xs); +} + +.p-sm { + padding: var(--c--theme--spacings--sm); +} + +.pb-sm { + padding-bottom: var(--c--theme--spacings--sm); +} + +.pt-sm { + padding-top: var(--c--theme--spacings--sm); +} + +.pl-sm { + padding-left: var(--c--theme--spacings--sm); +} + +.pr-sm { + padding-right: var(--c--theme--spacings--sm); +} + +.p-base { + padding: var(--c--theme--spacings--base); +} + +.pb-base { + padding-bottom: var(--c--theme--spacings--base); +} + +.pt-base { + padding-top: var(--c--theme--spacings--base); +} + +.pl-base { + padding-left: var(--c--theme--spacings--base); +} + +.pr-base { + padding-right: var(--c--theme--spacings--base); +} + +.p-md { + padding: var(--c--theme--spacings--md); +} + +.pb-md { + padding-bottom: var(--c--theme--spacings--md); +} + +.pt-md { + padding-top: var(--c--theme--spacings--md); +} + +.pl-md { + padding-left: var(--c--theme--spacings--md); +} + +.pr-md { + padding-right: var(--c--theme--spacings--md); +} + +.p-lg { + padding: var(--c--theme--spacings--lg); +} + +.pb-lg { + padding-bottom: var(--c--theme--spacings--lg); +} + +.pt-lg { + padding-top: var(--c--theme--spacings--lg); +} + +.pl-lg { + padding-left: var(--c--theme--spacings--lg); +} + +.pr-lg { + padding-right: var(--c--theme--spacings--lg); +} + +.p-xxl { + padding: var(--c--theme--spacings--xxl); +} + +.pb-xxl { + padding-bottom: var(--c--theme--spacings--xxl); +} + +.pt-xxl { + padding-top: var(--c--theme--spacings--xxl); +} + +.pl-xxl { + padding-left: var(--c--theme--spacings--xxl); +} + +.pr-xxl { + padding-right: var(--c--theme--spacings--xxl); +} + +.p-xxxl { + padding: var(--c--theme--spacings--xxxl); +} + +.pb-xxxl { + padding-bottom: var(--c--theme--spacings--xxxl); +} + +.pt-xxxl { + padding-top: var(--c--theme--spacings--xxxl); +} + +.pl-xxxl { + padding-left: var(--c--theme--spacings--xxxl); +} + +.pr-xxxl { + padding-right: var(--c--theme--spacings--xxxl); +} + +.p-4xl { + padding: var(--c--theme--spacings--4xl); +} + +.pb-4xl { + padding-bottom: var(--c--theme--spacings--4xl); +} + +.pt-4xl { + padding-top: var(--c--theme--spacings--4xl); +} + +.pl-4xl { + padding-left: var(--c--theme--spacings--4xl); +} + +.pr-4xl { + padding-right: var(--c--theme--spacings--4xl); +} + +.p-5xl { + padding: var(--c--theme--spacings--5xl); +} + +.pb-5xl { + padding-bottom: var(--c--theme--spacings--5xl); +} + +.pt-5xl { + padding-top: var(--c--theme--spacings--5xl); +} + +.pl-5xl { + padding-left: var(--c--theme--spacings--5xl); +} + +.pr-5xl { + padding-right: var(--c--theme--spacings--5xl); +} + +.p-6xl { + padding: var(--c--theme--spacings--6xl); +} + +.pb-6xl { + padding-bottom: var(--c--theme--spacings--6xl); +} + +.pt-6xl { + padding-top: var(--c--theme--spacings--6xl); +} + +.pl-6xl { + padding-left: var(--c--theme--spacings--6xl); +} + +.pr-6xl { + padding-right: var(--c--theme--spacings--6xl); +} + +.p-7xl { + padding: var(--c--theme--spacings--7xl); +} + +.pb-7xl { + padding-bottom: var(--c--theme--spacings--7xl); +} + +.pt-7xl { + padding-top: var(--c--theme--spacings--7xl); +} + +.pl-7xl { + padding-left: var(--c--theme--spacings--7xl); +} + +.pr-7xl { + padding-right: var(--c--theme--spacings--7xl); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts new file mode 100644 index 00000000..a547d659 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts @@ -0,0 +1,598 @@ +export const tokens = { + themes: { + default: { + theme: { + colors: { + 'secondary-text': '#fff', + 'secondary-100': '#fee9ea', + 'secondary-200': '#fedfdf', + 'secondary-300': '#fdbfbf', + 'secondary-400': '#e1020f', + 'secondary-500': '#c91a1f', + 'secondary-600': '#5e2b2b', + 'secondary-700': '#3b2424', + 'secondary-800': '#341f1f', + 'secondary-900': '#2b1919', + 'info-text': '#0078f3', + 'info-100': '#E8EDFF', + 'info-200': '#DDE5FF', + 'info-300': '#BCCDFF', + 'info-400': '#518FFF', + 'info-500': '#0078F3', + 'info-600': '#0063CB', + 'info-700': '#273961', + 'info-800': '#222A3F', + 'info-900': '#1D2437', + 'greyscale-100': '#eee', + 'greyscale-200': '#E5E5E5', + 'greyscale-300': '#CECECE', + 'greyscale-400': '#929292', + 'greyscale-500': '#7C7C7C', + 'greyscale-600': '#666666', + 'greyscale-700': '#3A3A3A', + 'greyscale-800': '#2A2A2A', + 'greyscale-900': '#242424', + 'greyscale-000': '#fff', + 'primary-100': '#ECECFE', + 'primary-200': '#E3E3FD', + 'primary-300': '#CACAFB', + 'primary-400': '#8585F6', + 'primary-500': '#6A6AF4', + 'primary-600': '#313178', + 'primary-700': '#272747', + 'primary-800': '#000091', + 'primary-900': '#21213F', + 'success-100': '#dffee6', + 'success-200': '#b8fec9', + 'success-300': '#88fdaa', + 'success-400': '#3bea7e', + 'success-500': '#1f8d49', + 'success-600': '#18753c', + 'success-700': '#204129', + 'success-800': '#1e2e22', + 'success-900': '#19281d', + 'warning-100': '#fff4f3', + 'warning-200': '#ffe9e6', + 'warning-300': '#ffded9', + 'warning-400': '#ffbeb4', + 'warning-500': '#d64d00', + 'warning-600': '#b34000', + 'warning-700': '#5e2c21', + 'warning-800': '#3e241e', + 'warning-900': '#361e19', + 'danger-100': '#FFE9E9', + 'danger-200': '#FFDDDD', + 'danger-300': '#FFBDBD', + 'danger-400': '#FF5655', + 'danger-500': '#F60700', + 'danger-600': '#CE0500', + 'danger-700': '#642626', + 'danger-800': '#412121', + 'danger-900': '#391C1C', + 'primary-text': '#000091', + 'success-text': '#1f8d49', + 'warning-text': '#d64d00', + 'danger-text': '#FFF', + 'primary-050': '#F5F5FE', + 'primary-150': '#F4F4FD', + 'greyscale-text': '#303C4B', + 'greyscale-050': '#F6F6F6', + 'greyscale-250': '#ddd', + 'greyscale-350': '#ddd', + 'greyscale-750': '#353535', + 'greyscale-950': '#1E1E1E', + 'greyscale-1000': '#161616', + 'danger-050': '#FFF4F4', + 'blue-500': '#417DC4', + 'brown-500': '#BD987A', + 'cyan-500': '#009099', + 'gold-500': '#C3992A', + 'green-500': '#00A95F', + 'olive-500': '#68A532', + 'orange-500': '#E4794A', + 'purple-500': '#A558A0', + 'red-500': '#E1000F', + 'yellow-500': '#B7A73F', + 'rose-500': '#E18B76', + 'primary-action': '#1212FF', + 'primary-bg': '#FAFAFA', + 'primary-focus': '#0A76F6', + 'secondary-icon': 'var(--c--theme--colors--primary-text)', + 'blue-400': '#7AB1E8', + 'blue-600': '#3558A2', + 'brown-400': '#E6BE92', + 'brown-600': '#745B47', + 'cyan-400': '#34BAB5', + 'cyan-600': '#006A6F', + 'gold-400': '#FFCA00', + 'gold-600': '#695240', + 'green-400': '#34CB6A', + 'green-600': '#297254', + 'olive-400': '#99C221', + 'olive-600': '#447049', + 'orange-400': '#FF732C', + 'orange-600': '#755348', + 'pink-400': '#FFB7AE', + 'pink-500': '#E18B76', + 'pink-600': '#8D533E', + 'purple-400': '#CE70CC', + 'purple-600': '#6E445A', + 'yellow-400': '#D8C634', + 'yellow-600': '#66673D', + }, + font: { + sizes: { + h1: '2rem', + h2: '1.75rem', + h3: '1.5rem', + h4: '1.375rem', + h5: '1.25rem', + h6: '1.125rem', + l: '1rem', + m: '0.8125rem', + s: '0.75rem', + xs: '0.75rem', + sm: '0.875rem', + md: '1rem', + lg: '1.125rem', + ml: '0.938rem', + xl: '1.25rem', + t: '0.6875rem', + 'xl-alt': '5rem', + 'lg-alt': '4.5rem', + 'md-alt': '4rem', + 'sm-alt': '3.5rem', + 'xs-alt': '3rem', + }, + weights: { + thin: 100, + light: 300, + regular: 400, + medium: 500, + bold: 600, + extrabold: 800, + black: 900, + }, + families: { base: 'Marianne', accent: 'Marianne' }, + letterSpacings: { + h1: 'normal', + h2: 'normal', + h3: 'normal', + h4: 'normal', + h5: '1px', + h6: 'normal', + l: 'normal', + m: 'normal', + s: 'normal', + }, + }, + spacings: { + '0': '0', + xl: '2.5rem', + l: '3rem', + b: '1.625rem', + s: '1rem', + t: '0.5rem', + st: '0.25rem', + none: '0', + auto: 'auto', + bx: '2.2rem', + full: '100%', + '4xs': '0.125rem', + '3xs': '0.25rem', + '2xs': '0.375rem', + xs: '0.5rem', + sm: '0.75rem', + base: '1rem', + md: '1.5rem', + lg: '2rem', + xxl: '3rem', + xxxl: '3.5rem', + '4xl': '4rem', + '5xl': '4.5rem', + '6xl': '6rem', + '7xl': '7.5rem', + }, + transitions: { + 'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)', + 'ease-out': 'cubic-bezier(0.33, 1, 0.68, 1)', + 'ease-in-out': 'cubic-bezier(0.65, 0, 0.35, 1)', + duration: '250ms', + }, + breakpoints: { + xs: '480px', + sm: '576px', + md: '768px', + lg: '992px', + xl: '1200px', + xxl: '1400px', + xxs: '320px', + mobile: '768px', + tablet: '1024px', + }, + logo: { src: '', alt: '', widthHeader: '', widthFooter: '' }, + }, + components: { + modal: { 'width-small': '342px' }, + tooltip: { padding: '4px 8px', 'background-color': '#161616' }, + button: { + 'medium-height': '40px', + 'medium-text-height': '40px', + 'border-radius': '4px', + 'small-height': '26px', + primary: { + 'background--color': '#000091', + 'background--color-hover': '#1212ff', + 'background--color-active': '#2323ff', + 'background--color-disabled': '#eee', + color: '#fff', + 'color-hover': '#fff', + 'color-active': '#fff', + 'color-focus-visible': '#fff', + disabled: '#7C7C7C', + }, + 'primary-text': { + 'background--color': '#000091', + 'background--color-hover': '#eee', + 'background--color-active': '#ECECFE', + 'background--color-focus-visible': '#fff', + 'background--color-disabled': '#fff', + color: '#000091', + 'color-hover': '#000091', + disabled: '#929292', + }, + secondary: { + 'background--color-hover': '#F6F6F6', + 'background--color-active': '#EDEDED', + 'background--color-focus-visible': '#fff', + 'background--disabled': '#fff', + color: '#000091', + 'border--color': '#CECECE', + 'border--color-hover': '#CECECE', + 'border--color-disabled': '#CECECE', + disabled: '#929292', + }, + tertiary: { + 'background--color': '#ECECFE', + 'background--color-focus-visible': '#ECECFE', + 'background--color-hover': '#CACAFB', + 'background--color-active': '#CACAFB', + 'background--disabled': '#F5F5FE', + color: '#000091', + disabled: '#CACAFB', + }, + 'tertiary-text': { + 'background--color-hover': '#eee', + 'color-hover': '#000091', + color: '#313178', + }, + danger: { + 'color-hover': 'white', + 'background--color': '#CE0500', + 'background--color-hover': '#FF2725', + 'background--color-focus-visible': '#CE0500', + 'background--color-disabled': '#eee', + 'color-disabled': '#929292', + }, + }, + datagrid: { + 'header--color': '#666666', + 'header--size': '12px', + 'header--weight': '500', + 'body--background-color-hover': '#eee', + }, + 'forms-checkbox': { + 'border-radius': '4px', + 'border-color': '#000091', + 'background-color--hover': '#eee', + 'border--color-disabled': '#E5E5E5', + 'border--color': '#000091', + 'background--disabled': '#E5E5E5', + 'background--enable': '#000091', + 'check--disabled': '#CECECE', + 'check--enable': '#fff', + color: '#000091', + 'label--color': '#161616', + 'label--size': '0.875rem', + 'label--weight': '500', + 'text--color': '#666666', + 'text--size': '0.75rem', + 'text--weight': '400', + 'text--color-disabled': '#CECECE', + }, + 'forms-labelledbox': { + 'label-color--small': '#1E1E1E', + 'label-color--small--disabled': '#CECECE', + 'label-color--big': '#1E1E1E', + 'label-color--big--disabled': '#CECECE', + }, + 'forms-radio': { + 'border-color': '#000091', + 'background-color': '#fff', + 'accent-color': '#000091', + 'accent-color-disabled': '#CECECE', + }, + 'forms-switch': { + 'border--color-disabled': '#CECECE', + 'border--color': '#000091', + 'handle-background-color': 'white', + 'handle-background-color--disabled': '#fff', + 'rail-background-color--disabled': '#fff', + 'accent-color': '#000091', + }, + 'forms-textarea': { + 'label-color--focus': '#161616', + 'border-radius': '4px', + 'border-color': '#929292', + 'box-shadow--color--hover': '#929292', + 'box-shadow--color--focus': '#000091', + 'value-color': '#1E1E1E', + 'value-color--disabled': '#CECECE', + 'font-size': '14px', + }, + 'forms-input': { + 'label-color--focus': '#161616', + 'border-radius': '4px', + 'border-color': '#929292', + 'box-shadow--color--hover': '#929292', + 'box-shadow--color--focus': '#000091', + 'value-color': '#1E1E1E', + 'value-color--disabled': '#CECECE', + 'font-size': '14px', + }, + 'forms-select': { + 'label-color--focus': '#161616', + 'item-font-size': '14px', + 'border-radius': '4px', + 'border-radius-hover': '4px', + 'border-color': '#929292', + 'box-shadow--color--hover': '#929292', + 'box-shadow--color--focus': '#000091', + 'value-color': '#1E1E1E', + 'font-size': '14px', + }, + badge: { + 'font-size': '0.75rem', + 'border-radius': '4px', + 'padding-inline': '0.5rem', + 'padding-block': '0.375rem', + accent: { 'background-color': '#ECECFE', color: '#313178' }, + neutral: { 'background-color': '#eee', color: '#666666' }, + danger: { 'background-color': '#FFE9E9', color: '#CE0500' }, + success: { 'background-color': '#dffee6', color: '#18753c' }, + warning: { 'background-color': '#fff4f3', color: '#b34000' }, + info: { 'background-color': '#E8EDFF', color: '#0063CB' }, + }, + 'la-gaufre': false, + 'home-proconnect': false, + beta: false, + 'image-system-filter': '', + favicon: { + ico: '/assets/favicon-light.ico', + 'png-light': '/assets/favicon-light.png', + 'png-dark': '/assets/favicon-dark.png', + }, + }, + }, + dark: { + theme: { + colors: { + 'greyscale-100': '#182536', + 'greyscale-200': '#303C4B', + 'greyscale-300': '#555F6B', + 'greyscale-400': '#79818A', + 'greyscale-500': '#9EA3AA', + 'greyscale-600': '#C2C6CA', + 'greyscale-700': '#E7E8EA', + 'greyscale-800': '#F3F4F4', + 'greyscale-900': '#FAFAFB', + 'greyscale-000': '#0C1A2B', + 'primary-100': '#3B4C62', + 'primary-200': '#4D6481', + 'primary-300': '#6381A6', + 'primary-400': '#7FA5D5', + 'primary-500': '#8CB5EA', + 'primary-600': '#A3C4EE', + 'primary-700': '#C3D8F4', + 'primary-800': '#DDE9F8', + 'primary-900': '#F4F8FD', + 'success-100': '#EEF8D7', + 'success-200': '#D9F1B2', + 'success-300': '#BDE985', + 'success-400': '#A0E25D', + 'success-500': '#76D628', + 'success-600': '#5BB520', + 'success-700': '#43941A', + 'success-800': '#307414', + 'success-900': '#225D10', + 'warning-100': '#F7F3D5', + 'warning-200': '#F0E5AA', + 'warning-300': '#E8D680', + 'warning-400': '#E3C95F', + 'warning-500': '#D9B32B', + 'warning-600': '#BD9721', + 'warning-700': '#9D7B1C', + 'warning-800': '#7E6016', + 'warning-900': '#684D12', + 'danger-100': '#F8D0D0', + 'danger-200': '#F09898', + 'danger-300': '#F09898', + 'danger-400': '#ED8585', + 'danger-500': '#E96666', + 'danger-600': '#DD6666', + 'danger-700': '#C36666', + 'danger-800': '#AE6666', + 'danger-900': '#9D6666', + }, + }, + }, + dsfr: { + theme: { + colors: { 'secondary-icon': '#C9191E' }, + logo: { + src: '/assets/logo-gouv.svg', + widthHeader: '110px', + widthFooter: '220px', + alt: 'Gouvernement Logo', + }, + }, + components: { + 'la-gaufre': true, + 'home-proconnect': true, + beta: true, + favicon: { + ico: '/assets/favicon-dsfr.ico', + 'png-light': '/assets/favicon-dsfr.png', + 'png-dark': '/assets/favicon-dark-dsfr.png', + }, + }, + }, + generic: { + theme: { + colors: { + 'primary-action': '#206EBD', + 'primary-focus': '#1E64BF', + 'primary-text': '#2E2C28', + 'primary-050': '#F8F8F7', + 'primary-100': '#F0EFEC', + 'primary-150': '#F4F4FD', + 'primary-200': '#E8E7E4', + 'primary-300': '#CFCDC9', + 'primary-400': '#979592', + 'primary-500': '#82807D', + 'primary-600': '#3F3D39', + 'primary-700': '#2E2C28', + 'primary-800': '#302E29', + 'primary-900': '#282622', + 'primary-950': '#201F1C', + 'secondary-text': '#fff', + 'secondary-50': '#F4F7FA', + 'secondary-100': '#D7E3EE', + 'secondary-200': '#B8CCE1', + 'secondary-300': '#99B4D3', + 'secondary-400': '#7595BE', + 'secondary-500': '#5874A0', + 'secondary-600': '#3A5383', + 'secondary-700': '#1E3462', + 'secondary-800': '#091B41', + 'secondary-900': '#08183B', + 'secondary-950': '#071636', + 'greyscale-text': '#3C3B38', + 'greyscale-000': '#fff', + 'greyscale-050': '#F8F7F7', + 'greyscale-100': '#F3F3F2', + 'greyscale-200': '#ECEBEA', + 'greyscale-250': '#E4E3E2', + 'greyscale-300': '#D3D2CF', + 'greyscale-350': '#eee', + 'greyscale-400': '#96948E', + 'greyscale-500': '#817E77', + 'greyscale-600': '#6A6862', + 'greyscale-700': '#3C3B38', + 'greyscale-750': '#383632', + 'greyscale-800': '#2D2B27', + 'greyscale-900': '#262522', + 'greyscale-950': '#201F1C', + 'greyscale-1000': '#181714', + 'success-text': '#234935', + 'success-50': '#F3FBF5', + 'success-100': '#E4F7EA', + 'success-200': '#CAEED4', + 'success-300': '#A0E0B5', + 'success-400': '#6CC88C', + 'success-500': '#6CC88C', + 'success-600': '#358D5C', + 'success-700': '#2D704B', + 'success-800': '#28583F', + 'success-900': '#234935', + 'success-950': '#0F281B', + 'info-text': '#212445', + 'info-50': '#F2F6FB', + 'info-100': '#E2E9F5', + 'info-200': '#CCD8EE', + 'info-300': '#A9C0E3', + 'info-400': '#809DD4', + 'info-500': '#617BC7', + 'info-600': '#4A5CBF', + 'info-700': '#3E49B2', + 'info-800': '#353C8F', + 'info-900': '#303771', + 'info-950': '#212445', + 'warning-text': '#D97C3A', + 'warning-50': '#FDF7F1', + 'warning-100': '#FBEDDC', + 'warning-200': '#F5D9B9', + 'warning-300': '#EDBE8C', + 'warning-400': '#E2985C', + 'warning-500': '#D97C3A', + 'warning-600': '#C96330', + 'warning-700': '#A34B32', + 'warning-800': '#813B2C', + 'warning-900': '#693327', + 'warning-950': '#381713', + 'danger-action': '#C0182A', + 'danger-text': '#FFF', + 'danger-050': '#FDF5F4', + 'danger-100': '#FBEBE8', + 'danger-200': '#F9E0DC', + 'danger-300': '#F3C3BD', + 'danger-400': '#E26552', + 'danger-500': '#C91F00', + 'danger-600': '#A71901', + 'danger-700': '#562C2B', + 'danger-800': '#392425', + 'danger-900': '#311F20', + 'danger-950': '#2A191A', + 'blue-400': '#8BAECC', + 'blue-500': '#567AA2', + 'blue-600': '#455784', + 'brown-400': '#E4C090', + 'brown-500': '#BA9977', + 'brown-600': '#735C45', + 'cyan-400': '#5CBEC9', + 'cyan-500': '#43A1B3', + 'cyan-600': '#39809B', + 'gold-400': '#ECBF50', + 'gold-500': '#DFA038', + 'gold-600': '#C17B31', + 'green-400': '#5DBD9A', + 'green-500': '#3AA183', + 'green-600': '#2A816D', + 'olive-400': '#AFD662', + 'olive-500': '#90BB4B', + 'olive-600': '#6E9441', + 'orange-400': '#E2985C', + 'orange-500': '#D97C3A', + 'orange-600': '#C96330', + 'pink-400': '#BE8FC8', + 'pink-500': '#A563B1', + 'pink-600': '#8B44A5', + 'purple-400': '#BE8FC8', + 'purple-500': '#A563B1', + 'purple-600': '#8B44A5', + 'yellow-400': '#EDC947', + 'yellow-500': '#DBB13A', + 'yellow-600': '#B88A34', + }, + font: { + families: { + base: 'Inter, Roboto Flex Variable, sans-serif', + accent: 'Inter, Roboto Flex Variable, sans-serif', + }, + }, + }, + components: { + button: { + primary: { + background: { + 'color-hover': 'var(--c--theme--colors--primary-focus)', + 'color-active': 'var(--c--theme--colors--primary-focus)', + 'color-focus': 'var(--c--theme--colors--primary-focus)', + }, + }, + }, + 'image-system-filter': 'saturate(0.2)', + }, + }, + }, +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/index.ts new file mode 100644 index 00000000..1e5bd52d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/index.ts @@ -0,0 +1,2 @@ +export * from './cunningham-tokens'; +export * from './useCunninghamTheme'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/useCunninghamTheme.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/useCunninghamTheme.tsx new file mode 100644 index 00000000..0e266199 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/cunningham/useCunninghamTheme.tsx @@ -0,0 +1,58 @@ +import merge from 'lodash/merge'; +import { create } from 'zustand'; + +import { tokens } from './cunningham-tokens'; + +type Tokens = typeof tokens.themes.default & + Partial<(typeof tokens.themes)[keyof typeof tokens.themes]>; +type ColorsTokens = Tokens['theme']['colors']; +type FontSizesTokens = Tokens['theme']['font']['sizes']; +type SpacingsTokens = Tokens['theme']['spacings']; +type ComponentTokens = Tokens['components']; +export type Theme = keyof typeof tokens.themes; + +interface ThemeStore { + colorsTokens: Partial; + componentTokens: ComponentTokens; + currentTokens: Partial; + fontSizesTokens: Partial; + setTheme: (theme: Theme) => void; + spacingsTokens: Partial; + theme: Theme; + themeTokens: Partial; +} + +const getMergedTokens = (theme: Theme) => { + return merge({}, tokens.themes['default'], tokens.themes[theme]); +}; + +const DEFAULT_THEME: Theme = 'generic'; +const defaultTokens = getMergedTokens(DEFAULT_THEME); + +const initialState: ThemeStore = { + colorsTokens: defaultTokens.theme.colors, + componentTokens: defaultTokens.components, + currentTokens: tokens.themes[DEFAULT_THEME] as Partial, + fontSizesTokens: defaultTokens.theme.font.sizes, + setTheme: () => {}, + spacingsTokens: defaultTokens.theme.spacings, + theme: DEFAULT_THEME, + themeTokens: defaultTokens.theme, +}; + +export const useCunninghamTheme = create((set) => ({ + ...initialState, + setTheme: (theme: Theme) => { + const newTokens = getMergedTokens(theme); + + set({ + colorsTokens: newTokens.theme.colors, + componentTokens: newTokens.components, + currentTokens: tokens.themes[theme] as Partial, + fontSizesTokens: newTokens.theme.font.sizes, + spacingsTokens: newTokens.theme.spacings, + theme, + themeTokens: newTokens.theme, + }); + }, +})); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/custom-next.d.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/custom-next.d.ts new file mode 100644 index 00000000..0e5e6acf --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/custom-next.d.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +declare module '*.svg' { + import * as React from 'react'; + + const ReactComponent: React.FunctionComponent< + React.SVGProps & { + title?: string; + } + >; + + export default ReactComponent; +} + +declare module '*.svg?url' { + const content: { + src: string; + width: number; + height: number; + blurWidth: number; + blurHeight: number; + }; + export default content; +} + +namespace NodeJS { + interface ProcessEnv { + NEXT_PUBLIC_API_ORIGIN?: string; + NEXT_PUBLIC_PUBLISH_AS_MIT?: string; + NEXT_PUBLIC_SW_DEACTIVATED?: string; + } +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/__tests__/utils.test.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/__tests__/utils.test.tsx new file mode 100644 index 00000000..2aa0a02c --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/__tests__/utils.test.tsx @@ -0,0 +1,40 @@ +import { Crisp } from 'crisp-sdk-web'; +import fetchMock from 'fetch-mock'; + +import { gotoLogout } from '../utils'; + +jest.mock('crisp-sdk-web', () => ({ + ...jest.requireActual('crisp-sdk-web'), + Crisp: { + isCrispInjected: jest.fn().mockReturnValue(true), + setTokenId: jest.fn(), + user: { + setEmail: jest.fn(), + }, + session: { + reset: jest.fn(), + }, + }, +})); + +describe('utils', () => { + afterEach(() => { + jest.clearAllMocks(); + fetchMock.restore(); + }); + + it('checks support session is terminated when logout', () => { + window.$crisp = true; + Object.defineProperty(window, 'location', { + value: { + ...window.location, + replace: jest.fn(), + }, + writable: true, + }); + + gotoLogout(); + + expect(Crisp.session.reset).toHaveBeenCalled(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/index.ts new file mode 100644 index 00000000..ce8db5d4 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/index.ts @@ -0,0 +1,2 @@ +export * from './useAuthQuery'; +export * from './types'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/types.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/types.ts new file mode 100644 index 00000000..6d911e51 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -0,0 +1,15 @@ +/** + * Represents user retrieved from the API. + * @interface User + * @property {string} id - The id of the user. + * @property {string} email - The email of the user. + * @property {string} name - The name of the user. + * @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'. + */ +export interface User { + id: string; + email: string; + full_name: string; + short_name: string; + language: string; +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx new file mode 100644 index 00000000..026beec9 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx @@ -0,0 +1,39 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { User } from './types'; + +/** + * Asynchronously retrieves the current user's data from the API. + * This function is called during frontend initialization to check + * the user's authentication status through a session cookie. + * + * @async + * @function getMe + * @throws {Error} Throws an error if the API request fails. + * @returns {Promise} A promise that resolves to the user data. + */ +export const getMe = async (): Promise => { + const response = await fetchAPI(`users/me/`); + if (!response.ok) { + throw new APIError( + `Couldn't fetch user data: ${response.statusText}`, + await errorCauses(response), + ); + } + return response.json() as Promise; +}; + +export const KEY_AUTH = 'auth'; + +export function useAuthQuery( + queryConfig?: UseQueryOptions, +) { + return useQuery({ + queryKey: [KEY_AUTH], + queryFn: getMe, + staleTime: 1000 * 60 * 15, // 15 minutes + ...queryConfig, + }); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/assets/button-proconnect.svg b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/assets/button-proconnect.svg new file mode 100644 index 00000000..10d1f499 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/assets/button-proconnect.svg @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/Auth.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/Auth.tsx new file mode 100644 index 00000000..addb481b --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/Auth.tsx @@ -0,0 +1,71 @@ +import { Loader } from '@openfun/cunningham-react'; +import { useRouter } from 'next/router'; +import { PropsWithChildren } from 'react'; + +import { Box } from '@/components'; +import { useConfig } from '@/core'; + +import { HOME_URL } from '../conf'; +import { useAuth } from '../hooks'; +import { getAuthUrl, gotoLogin } from '../utils'; + +export const Auth = ({ children }: PropsWithChildren) => { + const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } = + useAuth(); + const { replace, pathname } = useRouter(); + const { data: config } = useConfig(); + + if (isLoading && !isFetchedAfterMount) { + return ( + + + + ); + } + + /** + * If the user is authenticated and wanted initially to access a document, + * we redirect to the document page. + */ + if (authenticated) { + const authUrl = getAuthUrl(); + if (authUrl) { + void replace(authUrl); + return ( + + + + ); + } + } + + /** + * If the user is not authenticated and the path is not allowed, we redirect to the login page. + */ + if (!authenticated && !pathAllowed) { + if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) { + void replace(HOME_URL); + } else { + gotoLogin(); + } + return ( + + + + ); + } + + /** + * If the user is authenticated and the path is the home page, we redirect to the index. + */ + if (pathname === HOME_URL && authenticated) { + void replace('/'); + return ( + + + + ); + } + + return children; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx new file mode 100644 index 00000000..e3c123da --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx @@ -0,0 +1,59 @@ +import { Button } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { BoxButton } from '@/components'; + +import ProConnectImg from '../assets/button-proconnect.svg'; +import { useAuth } from '../hooks'; +import { gotoLogin, gotoLogout } from '../utils'; + +export const ButtonLogin = () => { + const { t } = useTranslation(); + const { authenticated } = useAuth(); + + if (!authenticated) { + return ( + + ); + } + + return ( + + ); +}; + +export const ProConnectButton = () => { + const { t } = useTranslation(); + + return ( + gotoLogin()} + aria-label={t('Proconnect Login')} + $css={css` + background-color: var(--c--theme--colors--primary-text); + &:hover { + background-color: var(--c--theme--colors--primary-action); + } + `} + $radius="4px" + className="--docs--proconnect-button" + > + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/index.ts new file mode 100644 index 00000000..17f3a905 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/components/index.ts @@ -0,0 +1,2 @@ +export * from './Auth'; +export * from './ButtonLogin'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/conf.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/conf.ts new file mode 100644 index 00000000..c44fe018 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/conf.ts @@ -0,0 +1,6 @@ +import { baseApiUrl } from '@/api'; + +export const HOME_URL = '/home'; +export const LOGIN_URL = `${baseApiUrl()}authenticate/`; +export const LOGOUT_URL = `${baseApiUrl()}logout/`; +export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/__tests__/useAuth.test.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/__tests__/useAuth.test.tsx new file mode 100644 index 00000000..56c56df3 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/__tests__/useAuth.test.tsx @@ -0,0 +1,81 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { Fragment } from 'react'; + +import { AbstractAnalytic } from '@/libs'; +import { AppWrapper } from '@/tests/utils'; + +import { useAuth } from '../useAuth'; + +const trackEventMock = jest.fn(); +const flag = true; +class TestAnalytic extends AbstractAnalytic { + public constructor() { + super(); + } + + public Provider() { + return ; + } + + public trackEvent(props: any) { + trackEventMock(props); + } + + public isFeatureFlagActivated(flagName: string): boolean { + if (flagName === 'CopyAsHTML') { + return flag; + } + + return true; + } +} + +jest.mock('next/router', () => ({ + ...jest.requireActual('next/router'), + useRouter: () => ({ + pathname: '/dashboard', + replace: jest.fn(), + }), +})); + +const dummyUser = { id: '123', email: 'test@example.com' }; + +describe('useAuth hook - trackEvent effect', () => { + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.restore(); + }); + + test('calls trackEvent when user exists, isSuccess is true, and event was not tracked yet', async () => { + new TestAnalytic(); + + fetchMock.get('http://test.jest/api/v1.0/users/me/', { + body: JSON.stringify(dummyUser), + }); + + renderHook(() => useAuth(), { + wrapper: AppWrapper, + }); + + await waitFor(() => { + expect(trackEventMock).toHaveBeenCalledWith({ + eventName: 'user', + id: dummyUser.id, + email: dummyUser.email, + }); + }); + }); + + test('does not call trackEvent if already tracked', () => { + fetchMock.get('http://test.jest/api/v1.0/users/me/', { + body: JSON.stringify(dummyUser), + }); + + renderHook(() => useAuth(), { + wrapper: AppWrapper, + }); + + expect(trackEventMock).not.toHaveBeenCalled(); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/index.ts new file mode 100644 index 00000000..d9ae7204 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/index.ts @@ -0,0 +1 @@ +export * from './useAuth'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/useAuth.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/useAuth.tsx new file mode 100644 index 00000000..dc9054da --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/hooks/useAuth.tsx @@ -0,0 +1,40 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; + +import { useAnalytics } from '@/libs'; + +import { useAuthQuery } from '../api'; + +const regexpUrlsAuth = [/\/docs\/$/g, /\/docs$/g, /^\/$/g]; + +export const useAuth = () => { + const { data: user, ...authStates } = useAuthQuery(); + const { pathname } = useRouter(); + const { trackEvent } = useAnalytics(); + const [hasTracked, setHasTracked] = useState(authStates.isFetched); + const [pathAllowed, setPathAllowed] = useState( + !regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)), + ); + + useEffect(() => { + setPathAllowed(!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp))); + }, [pathname]); + + useEffect(() => { + if (!hasTracked && user && authStates.isSuccess) { + trackEvent({ + eventName: 'user', + id: user?.id || '', + email: user?.email || '', + }); + setHasTracked(true); + } + }, [hasTracked, authStates.isSuccess, user, trackEvent]); + + return { + user, + authenticated: !!user && authStates.isSuccess, + pathAllowed, + ...authStates, + }; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/index.ts new file mode 100644 index 00000000..c044307f --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/index.ts @@ -0,0 +1,5 @@ +export * from './api'; +export * from './components'; +export * from './conf'; +export * from './hooks'; +export * from './utils'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/utils.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/utils.ts new file mode 100644 index 00000000..41d50cf0 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/auth/utils.ts @@ -0,0 +1,30 @@ +import { terminateCrispSession } from '@/services/Crisp'; + +import { LOGIN_URL, LOGOUT_URL, PATH_AUTH_LOCAL_STORAGE } from './conf'; + +export const getAuthUrl = () => { + const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE); + if (path_auth) { + localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE); + return path_auth; + } +}; + +export const setAuthUrl = () => { + if (window.location.pathname !== '/') { + localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname); + } +}; + +export const gotoLogin = (withRedirect = true) => { + if (withRedirect) { + setAuthUrl(); + } + + window.location.replace(LOGIN_URL); +}; + +export const gotoLogout = () => { + terminateCrispSession(); + window.location.replace(LOGOUT_URL); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/checkDocMediaStatus.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/checkDocMediaStatus.tsx new file mode 100644 index 00000000..81e3825e --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/checkDocMediaStatus.tsx @@ -0,0 +1,27 @@ +import { APIError, errorCauses } from '@/api'; + +interface CheckDocMediaStatusResponse { + file?: string; + status: 'processing' | 'ready'; +} + +interface CheckDocMediaStatus { + urlMedia: string; +} + +export const checkDocMediaStatus = async ({ + urlMedia, +}: CheckDocMediaStatus): Promise => { + const response = await fetch(urlMedia, { + credentials: 'include', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to check the media status', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/index.ts new file mode 100644 index 00000000..040f6c7c --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/index.ts @@ -0,0 +1,4 @@ +export * from './checkDocMediaStatus'; +export * from './useCreateDocUpload'; +export * from './useDocAITransform'; +export * from './useDocAITranslate'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useCreateDocUpload.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useCreateDocUpload.tsx new file mode 100644 index 00000000..2d605d5d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useCreateDocUpload.tsx @@ -0,0 +1,36 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { DocAttachment } from '../types'; + +interface CreateDocAttachment { + docId: string; + body: FormData; +} + +export const createDocAttachment = async ({ + docId, + body, +}: CreateDocAttachment): Promise => { + const response = await fetchAPI(`documents/${docId}/attachment-upload/`, { + method: 'POST', + body, + withoutContentType: true, + }); + + if (!response.ok) { + throw new APIError( + 'Failed to upload on the doc', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export function useCreateDocAttachment() { + return useMutation({ + mutationFn: createDocAttachment, + }); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITransform.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITransform.tsx new file mode 100644 index 00000000..cd8dfbfc --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITransform.tsx @@ -0,0 +1,48 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +export type AITransformActions = + | 'correct' + | 'prompt' + | 'rephrase' + | 'summarize' + | 'beautify' + | 'emojify'; + +export type DocAITransform = { + docId: string; + text: string; + action: AITransformActions; +}; + +export type DocAITransformResponse = { + answer: string; +}; + +export const docAITransform = async ({ + docId, + ...params +}: DocAITransform): Promise => { + const response = await fetchAPI(`documents/${docId}/ai-transform/`, { + method: 'POST', + body: JSON.stringify({ + ...params, + }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to request ai transform', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export function useDocAITransform() { + return useMutation({ + mutationFn: docAITransform, + }); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx new file mode 100644 index 00000000..504d79b3 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx @@ -0,0 +1,40 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +export type DocAITranslate = { + docId: string; + text: string; + language: string; +}; + +export type DocAITranslateResponse = { + answer: string; +}; + +export const docAITranslate = async ({ + docId, + ...params +}: DocAITranslate): Promise => { + const response = await fetchAPI(`documents/${docId}/ai-translate/`, { + method: 'POST', + body: JSON.stringify({ + ...params, + }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to request ai translate', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export function useDocAITranslate() { + return useMutation({ + mutationFn: docAITranslate, + }); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/assets/loader.svg b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/assets/loader.svg new file mode 100644 index 00000000..c3a51be9 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/assets/loader.svg @@ -0,0 +1,27 @@ + + + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/assets/warning.svg b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/assets/warning.svg new file mode 100644 index 00000000..531a6260 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/assets/warning.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx new file mode 100644 index 00000000..7ed06af1 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -0,0 +1,211 @@ +import { codeBlock } from '@blocknote/code-block'; +import { + BlockNoteSchema, + defaultBlockSpecs, + withPageBreak, +} from '@blocknote/core'; +import '@blocknote/core/fonts/inter.css'; +import * as locales from '@blocknote/core/locales'; +import { BlockNoteView } from '@blocknote/mantine'; +import '@blocknote/mantine/style.css'; +import { useCreateBlockNote } from '@blocknote/react'; +import { HocuspocusProvider } from '@hocuspocus/provider'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import * as Y from 'yjs'; + +import { Box, TextErrors } from '@/components'; +import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; +import { useAuth } from '@/features/auth'; + +import { useHeadings, useUploadFile, useUploadStatus } from '../hook/'; +import useSaveDoc from '../hook/useSaveDoc'; +import { useEditorStore } from '../stores'; +import { cssEditor } from '../styles'; +import { DocsBlockNoteEditor } from '../types'; +import { randomColor } from '../utils'; + +import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; +import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; +import { CalloutBlock, DividerBlock } from './custom-blocks'; + +export const blockNoteSchema = withPageBreak( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + callout: CalloutBlock, + divider: DividerBlock, + }, + }), +); + +interface BlockNoteEditorProps { + doc: Doc; + provider: HocuspocusProvider; +} + +export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { + const { user } = useAuth(); + const { setEditor } = useEditorStore(); + const { t } = useTranslation(); + + const { isEditable, isLoading } = useIsCollaborativeEditable(doc); + const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; + + useSaveDoc(doc.id, provider.document, !readOnly); + const { i18n } = useTranslation(); + const lang = i18n.resolvedLanguage; + + const { uploadFile, errorAttachment } = useUploadFile(doc.id); + + const collabName = readOnly + ? 'Reader' + : user?.full_name || user?.email || t('Anonymous'); + const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity'; + + const editor: DocsBlockNoteEditor = useCreateBlockNote( + { + codeBlock, + collaboration: { + provider, + fragment: provider.document.getXmlFragment('document-store'), + user: { + name: collabName, + color: randomColor(), + }, + /** + * We render the cursor with a custom element to: + * - fix rendering issue with the default cursor + * - hide the cursor when anonymous users + */ + renderCursor: (user: { color: string; name: string }) => { + const cursorElement = document.createElement('span'); + + if (user.name === 'Reader') { + return cursorElement; + } + + cursorElement.classList.add('collaboration-cursor-custom__base'); + const caretElement = document.createElement('span'); + caretElement.classList.add('collaboration-cursor-custom__caret'); + caretElement.setAttribute('spellcheck', `false`); + caretElement.setAttribute('style', `background-color: ${user.color}`); + + if (showCursorLabels === 'always') { + cursorElement.setAttribute('data-active', ''); + } + + const labelElement = document.createElement('span'); + + labelElement.classList.add('collaboration-cursor-custom__label'); + labelElement.setAttribute('spellcheck', `false`); + labelElement.setAttribute( + 'style', + `background-color: ${user.color};border: 1px solid ${user.color};`, + ); + labelElement.insertBefore(document.createTextNode(user.name), null); + + caretElement.insertBefore(labelElement, null); + + cursorElement.insertBefore(document.createTextNode('\u2060'), null); // Non-breaking space + cursorElement.insertBefore(caretElement, null); + cursorElement.insertBefore(document.createTextNode('\u2060'), null); // Non-breaking space + + return cursorElement; + }, + showCursorLabels: showCursorLabels as 'always' | 'activity', + }, + dictionary: locales[lang as keyof typeof locales], + tables: { + splitCells: true, + cellBackgroundColor: true, + cellTextColor: true, + headers: true, + }, + uploadFile, + schema: blockNoteSchema, + }, + [collabName, lang, provider, uploadFile], + ); + + useHeadings(editor); + useUploadStatus(editor); + + useEffect(() => { + setEditor(editor); + + return () => { + setEditor(undefined); + }; + }, [setEditor, editor]); + + return ( + + {errorAttachment && ( + + + + )} + + + + + + + ); +}; + +interface BlockNoteEditorVersionProps { + initialContent: Y.XmlFragment; +} + +export const BlockNoteEditorVersion = ({ + initialContent, +}: BlockNoteEditorVersionProps) => { + const readOnly = true; + const { setEditor } = useEditorStore(); + const editor = useCreateBlockNote( + { + collaboration: { + fragment: initialContent, + user: { + name: '', + color: '', + }, + provider: undefined, + }, + schema: blockNoteSchema, + }, + [initialContent], + ); + useHeadings(editor); + + useEffect(() => { + setEditor(editor); + + return () => { + setEditor(undefined); + }; + }, [setEditor, editor]); + + return ( + + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx new file mode 100644 index 00000000..3122b1c1 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -0,0 +1,45 @@ +import { combineByGroup, filterSuggestionItems } from '@blocknote/core'; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + getPageBreakReactSlashMenuItems, + useBlockNoteEditor, + useDictionary, +} from '@blocknote/react'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DocsBlockSchema } from '../types'; + +import { + getCalloutReactSlashMenuItems, + getDividerReactSlashMenuItems, +} from './custom-blocks'; + +export const BlockNoteSuggestionMenu = () => { + const editor = useBlockNoteEditor(); + const { t } = useTranslation(); + const basicBlocksName = useDictionary().slash_menu.page_break.group; + + const getSlashMenuItems = useMemo(() => { + return async (query: string) => + Promise.resolve( + filterSuggestionItems( + combineByGroup( + getDefaultReactSlashMenuItems(editor), + getPageBreakReactSlashMenuItems(editor), + getCalloutReactSlashMenuItems(editor, t, basicBlocksName), + getDividerReactSlashMenuItems(editor, t, basicBlocksName), + ), + query, + ), + ); + }, [basicBlocksName, editor, t]); + + return ( + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/AIButton.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/AIButton.tsx new file mode 100644 index 00000000..45bd1ed4 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/AIButton.tsx @@ -0,0 +1,371 @@ +import { Block } from '@blocknote/core'; +import { + ComponentProps, + useBlockNoteEditor, + useComponentsContext, + useSelectedBlocks, +} from '@blocknote/react'; +import { + Loader, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { PropsWithChildren, ReactNode, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { isAPIError } from '@/api'; +import { Box, Icon } from '@/components'; +import { useDocOptions, useDocStore } from '@/docs/doc-management/'; + +import { + AITransformActions, + useDocAITransform, + useDocAITranslate, +} from '../../api'; + +type LanguageTranslate = { + value: string; + display_name: string; +}; + +const sortByPopularLanguages = ( + languages: LanguageTranslate[], + popularLanguages: string[], +) => { + languages.sort((a, b) => { + const indexA = popularLanguages.indexOf(a.value); + const indexB = popularLanguages.indexOf(b.value); + + // If both languages are in the popular list, sort based on their order in popularLanguages + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + + // If only a is in the popular list, it should come first + if (indexA !== -1) { + return -1; + } + + // If only b is in the popular list, it should come first + if (indexB !== -1) { + return 1; + } + + // If neither a nor b is in the popular list, maintain their relative order + return 0; + }); +}; + +export function AIGroupButton() { + const editor = useBlockNoteEditor(); + const Components = useComponentsContext(); + const selectedBlocks = useSelectedBlocks(editor); + const { t } = useTranslation(); + const { currentDoc } = useDocStore(); + const { data: docOptions } = useDocOptions(); + + const languages = useMemo(() => { + const languages = docOptions?.actions.POST.language.choices; + + if (!languages) { + return; + } + + sortByPopularLanguages(languages, [ + 'fr', + 'en', + 'de', + 'es', + 'it', + 'pt', + 'nl', + 'pl', + ]); + + return languages; + }, [docOptions?.actions.POST.language.choices]); + + const show = useMemo(() => { + return !!selectedBlocks.find((block) => block.content !== undefined); + }, [selectedBlocks]); + + if (!show || !editor.isEditable || !Components || !currentDoc || !languages) { + return null; + } + + const canAITransform = currentDoc.abilities.ai_transform; + const canAITranslate = currentDoc.abilities.ai_translate; + + if (!canAITransform && !canAITranslate) { + return null; + } + + return ( + + + } + /> + + + {canAITransform && ( + <> + } + > + {t('Use as prompt')} + + } + > + {t('Rephrase')} + + } + > + {t('Summarize')} + + } + > + {t('Correct')} + + } + > + {t('Beautify')} + + } + > + {t('Emojify')} + + + )} + {canAITranslate && ( + + + + + + {t('Language')} + + + + + {languages.map((language) => ( + + {language.display_name} + + ))} + + + )} + + + ); +} + +/** + * Item is derived from Mantime, some props seem lacking or incorrect. + */ +type ItemDefault = ComponentProps['Generic']['Menu']['Item']; +type ItemProps = Omit & { + rightSection?: ReactNode; + closeMenuOnClick?: boolean; + onClick: (e: React.MouseEvent) => void; +}; + +interface AIMenuItemTransform { + action: AITransformActions; + docId: string; + icon?: ReactNode; +} + +const AIMenuItemTransform = ({ + docId, + action, + children, + icon, +}: PropsWithChildren) => { + const { mutateAsync: requestAI, isPending } = useDocAITransform(); + const editor = useBlockNoteEditor(); + + const requestAIAction = async (selectedBlocks: Block[]) => { + const text = await editor.blocksToMarkdownLossy(selectedBlocks); + + const responseAI = await requestAI({ + text, + action, + docId, + }); + + if (!responseAI?.answer) { + throw new Error('No response from AI'); + } + + const markdown = await editor.tryParseMarkdownToBlocks(responseAI.answer); + editor.replaceBlocks(selectedBlocks, markdown); + }; + + return ( + + {children} + + ); +}; + +interface AIMenuItemTranslate { + language: string; + docId: string; + icon?: ReactNode; +} + +const AIMenuItemTranslate = ({ + children, + docId, + icon, + language, +}: PropsWithChildren) => { + const { mutateAsync: requestAI, isPending } = useDocAITranslate(); + const editor = useBlockNoteEditor(); + + const requestAITranslate = async (selectedBlocks: Block[]) => { + let fullHtml = ''; + for (const block of selectedBlocks) { + if (Array.isArray(block.content) && block.content.length === 0) { + fullHtml += '


'; + continue; + } + + fullHtml += await editor.blocksToHTMLLossy([block]); + } + + const responseAI = await requestAI({ + text: fullHtml, + language, + docId, + }); + + if (!responseAI || !responseAI.answer) { + throw new Error('No response from AI'); + } + + try { + const blocks = await editor.tryParseHTMLToBlocks(responseAI.answer); + editor.replaceBlocks(selectedBlocks, blocks); + } catch { + editor.replaceBlocks(selectedBlocks, selectedBlocks); + } + }; + + return ( + + {children} + + ); +}; + +interface AIMenuItemProps { + requestAI: (blocks: Block[]) => Promise; + isPending: boolean; + icon?: ReactNode; +} + +const AIMenuItem = ({ + requestAI, + isPending, + children, + icon, +}: PropsWithChildren) => { + const Components = useComponentsContext(); + const { toast } = useToastProvider(); + const { t } = useTranslation(); + + const editor = useBlockNoteEditor(); + const handleAIError = useHandleAIError(); + + const handleAIAction = async () => { + const selectedBlocks = editor.getSelection()?.blocks ?? [ + editor.getTextCursorPosition().block, + ]; + + if (!selectedBlocks?.length) { + toast(t('No text selected'), VariantType.WARNING); + return; + } + + try { + await requestAI(selectedBlocks); + } catch (error) { + handleAIError(error); + } + }; + + if (!Components) { + return null; + } + + const Item = Components.Generic.Menu.Item as React.FC; + + return ( + { + e.stopPropagation(); + void handleAIAction(); + }} + rightSection={isPending ? : undefined} + > + {children} + + ); +}; + +const useHandleAIError = () => { + const { toast } = useToastProvider(); + const { t } = useTranslation(); + + return (error: unknown) => { + if (isAPIError(error) && error.status === 429) { + toast(t('Too many requests. Please wait 60 seconds.'), VariantType.ERROR); + return; + } + + toast(t('AI seems busy! Please try again.'), VariantType.ERROR); + }; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx new file mode 100644 index 00000000..d59a09ab --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx @@ -0,0 +1,81 @@ +import { + FormattingToolbar, + FormattingToolbarController, + blockTypeSelectItems, + getFormattingToolbarItems, + useDictionary, +} from '@blocknote/react'; +import React, { JSX, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useConfig } from '@/core/config/api'; + +import { getCalloutFormattingToolbarItems } from '../custom-blocks'; + +import { AIGroupButton } from './AIButton'; +import { FileDownloadButton } from './FileDownloadButton'; +import { MarkdownButton } from './MarkdownButton'; +import { ModalConfirmDownloadUnsafe } from './ModalConfirmDownloadUnsafe'; + +export const BlockNoteToolbar = () => { + const dict = useDictionary(); + const [confirmOpen, setIsConfirmOpen] = useState(false); + const [onConfirm, setOnConfirm] = useState<() => void | Promise>(); + const { t } = useTranslation(); + const { data: conf } = useConfig(); + + const toolbarItems = useMemo(() => { + const toolbarItems = getFormattingToolbarItems([ + ...blockTypeSelectItems(dict), + getCalloutFormattingToolbarItems(t), + ]); + const fileDownloadButtonIndex = toolbarItems.findIndex( + (item) => + typeof item === 'object' && + item !== null && + 'key' in item && + (item as { key: string }).key === 'fileDownloadButton', + ); + if (fileDownloadButtonIndex !== -1) { + toolbarItems.splice( + fileDownloadButtonIndex, + 1, + { + setIsConfirmOpen(true); + setOnConfirm(() => onConfirm); + }} + />, + ); + } + + return toolbarItems as JSX.Element[]; + }, [dict, t]); + + const formattingToolbar = useCallback(() => { + return ( + + {toolbarItems} + + {/* Extra button to do some AI powered actions */} + {conf?.AI_FEATURE_ENABLED && } + + {/* Extra button to convert from markdown to json */} + + + ); + }, [toolbarItems, conf?.AI_FEATURE_ENABLED]); + + return ( + <> + + {confirmOpen && ( + setIsConfirmOpen(false)} + onConfirm={onConfirm} + /> + )} + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx new file mode 100644 index 00000000..e0c31847 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx @@ -0,0 +1,116 @@ +import { + BlockSchema, + InlineContentSchema, + StyleSchema, + checkBlockIsFileBlock, + checkBlockIsFileBlockWithPlaceholder, +} from '@blocknote/core'; +import { + useBlockNoteEditor, + useComponentsContext, + useDictionary, + useSelectedBlocks, +} from '@blocknote/react'; +import { useCallback, useMemo } from 'react'; +import { RiDownload2Fill } from 'react-icons/ri'; + +import { downloadFile, exportResolveFileUrl } from '@/docs/doc-export'; +import { isSafeUrl } from '@/utils/url'; + +export const FileDownloadButton = ({ + open, +}: { + open: (onConfirm: () => Promise | void) => void; +}) => { + const dict = useDictionary(); + const Components = useComponentsContext(); + + const editor = useBlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >(); + + const selectedBlocks = useSelectedBlocks(editor); + + const fileBlock = useMemo(() => { + // Checks if only one block is selected. + if (selectedBlocks.length !== 1) { + return undefined; + } + + const block = selectedBlocks[0]; + + if (checkBlockIsFileBlock(block, editor)) { + return block; + } + + return undefined; + }, [editor, selectedBlocks]); + + const onClick = useCallback(async () => { + if (fileBlock && fileBlock.props.url) { + editor.focus(); + + const url = fileBlock.props.url as string; + + /** + * If not hosted on our domain, means not a file uploaded by the user, + * we do what Blocknote was doing initially. + */ + if (!url.includes(window.location.hostname) && !url.includes('base64')) { + if (!editor.resolveFileUrl) { + if (!isSafeUrl(url)) { + return; + } + + window.open(url, '_blank', 'noopener,noreferrer'); + } else { + void editor + .resolveFileUrl(url) + .then((downloadUrl) => window.open(downloadUrl)); + } + + return; + } + + if (!url.includes('-unsafe')) { + const blob = (await exportResolveFileUrl(url)) as Blob; + downloadFile(blob, url.split('/').pop() || 'file'); + } else { + const onConfirm = async () => { + const blob = (await exportResolveFileUrl(url)) as Blob; + downloadFile(blob, url.split('/').pop() || 'file (unsafe)'); + }; + + open(onConfirm); + } + } + }, [editor, fileBlock, open]); + + if ( + !fileBlock || + checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) || + !Components + ) { + return null; + } + + return ( + <> + } + onClick={() => void onClick()} + /> + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx new file mode 100644 index 00000000..35895621 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx @@ -0,0 +1,90 @@ +import { + useBlockNoteEditor, + useComponentsContext, + useSelectedBlocks, +} from '@blocknote/react'; +import { forEach, isArray } from 'lodash'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Block = { + type: string; + text: string; + content: Block[]; +}; + +function isBlock(block: Block): block is Block { + return ( + block.content && + isArray(block.content) && + block.content.length > 0 && + typeof block.type !== 'undefined' + ); +} + +const recursiveContent = (content: Block[], base: string = '') => { + let fullContent = base; + for (const innerContent of content) { + if (innerContent.type === 'text') { + fullContent += innerContent.text; + } else if (isBlock(innerContent)) { + fullContent = recursiveContent(innerContent.content, fullContent); + } + } + + return fullContent; +}; + +/** + * Custom Formatting Toolbar Button to convert markdown to json. + */ +export function MarkdownButton() { + const editor = useBlockNoteEditor(); + const Components = useComponentsContext(); + const selectedBlocks = useSelectedBlocks(editor); + const { t } = useTranslation(); + + const handleConvertMarkdown = () => { + let blocks = editor.getSelection()?.blocks; + + if (!blocks || blocks.length === 0) { + blocks = [editor.getTextCursorPosition().block]; + } + + forEach(blocks, async (block) => { + if (!isBlock(block as unknown as Block)) { + return; + } + + try { + const fullContent = recursiveContent( + block.content as unknown as Block[], + ); + + const blockMarkdown = + await editor.tryParseMarkdownToBlocks(fullContent); + editor.replaceBlocks([block.id], blockMarkdown); + } catch (error) { + console.error('Error parsing Markdown:', error); + } + }); + }; + + const show = useMemo(() => { + return !!selectedBlocks.find((block) => block.content !== undefined); + }, [selectedBlocks]); + + if (!show || !editor.isEditable || !Components) { + return null; + } + + return ( + + M + + ); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx new file mode 100644 index 00000000..3929175c --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx @@ -0,0 +1,74 @@ +import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Icon, Text } from '@/components'; + +interface ModalConfirmDownloadUnsafeProps { + onClose: () => void; + onConfirm?: () => Promise | void; +} + +export const ModalConfirmDownloadUnsafe = ({ + onConfirm, + onClose, +}: ModalConfirmDownloadUnsafeProps) => { + const { t } = useTranslation(); + + return ( + onClose()} + rightActions={ + <> + + + + } + size={ModalSize.SMALL} + title={ + + + {t('Warning')} + + } + > + + + + {t('This file is flagged as unsafe.')} + + {t('Please download it only if it comes from a trusted source.')} + + + + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx new file mode 100644 index 00000000..6f07096e --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -0,0 +1,150 @@ +import { Loader } from '@openfun/cunningham-react'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { css } from 'styled-components'; +import * as Y from 'yjs'; + +import { Box, Text, TextErrors } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { DocHeader, DocVersionHeader } from '@/docs/doc-header/'; +import { + Doc, + base64ToBlocknoteXmlFragment, + useProviderStore, +} from '@/docs/doc-management'; +import { TableContent } from '@/docs/doc-table-content/'; +import { Versions, useDocVersion } from '@/docs/doc-versioning/'; +import { useResponsiveStore } from '@/stores'; + +import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor'; + +interface DocEditorProps { + doc: Doc; + versionId?: Versions['version_id']; +} + +export const DocEditor = ({ doc, versionId }: DocEditorProps) => { + const { isDesktop } = useResponsiveStore(); + const isVersion = !!versionId && typeof versionId === 'string'; + + const { colorsTokens } = useCunninghamTheme(); + + const { provider } = useProviderStore(); + + if (!provider) { + return null; + } + + return ( + <> + {isDesktop && !isVersion && ( + + + + )} + + + {isVersion ? ( + + ) : ( + + )} + + + + + {isVersion ? ( + + ) : ( + + )} + + + + + ); +}; + +interface DocVersionEditorProps { + docId: Doc['id']; + versionId: Versions['version_id']; +} + +export const DocVersionEditor = ({ + docId, + versionId, +}: DocVersionEditorProps) => { + const { + data: version, + isLoading, + isError, + error, + } = useDocVersion({ + docId, + versionId, + }); + + const { replace } = useRouter(); + const [initialContent, setInitialContent] = useState(); + + useEffect(() => { + if (!version?.content) { + return; + } + + setInitialContent(base64ToBlocknoteXmlFragment(version.content)); + }, [version?.content]); + + if (isError && error) { + if (error.status === 404) { + void replace(`/404`); + return null; + } + + return ( + + + wifi_off + + ) : undefined + } + /> + + ); + } + + if (isLoading || !version || !initialContent) { + return ( + + + + ); + } + + return ; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx new file mode 100644 index 00000000..f2ff859b --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx @@ -0,0 +1,43 @@ +import data from '@emoji-mart/data'; +import Picker from '@emoji-mart/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; + +interface EmojiPickerProps { + categories: string[]; + custom: { + name: string; + id: string; + emojis: string[]; + }[]; + onClickOutside: () => void; + onEmojiSelect: ({ native }: { native: string }) => void; +} + +export const EmojiPicker = ({ + categories, + custom, + onClickOutside, + onEmojiSelect, +}: EmojiPickerProps) => { + const { i18n } = useTranslation(); + + return ( + + + + ); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx new file mode 100644 index 00000000..bbb0850c --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx @@ -0,0 +1,166 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { defaultProps, insertOrUpdateBlock } from '@blocknote/core'; +import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react'; +import { TFunction } from 'i18next'; +import React, { useEffect, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, BoxButton, Icon } from '@/components'; + +import { DocsBlockNoteEditor } from '../../types'; +import { EmojiPicker } from '../EmojiPicker'; + +const calloutCustom = [ + { + name: 'Callout', + id: 'callout', + emojis: [ + 'bulb', + 'point_right', + 'point_up', + 'ok_hand', + 'key', + 'construction', + 'warning', + 'fire', + 'pushpin', + 'scissors', + 'question', + 'no_entry', + 'no_entry_sign', + 'alarm_clock', + 'phone', + 'rotating_light', + 'recycle', + 'white_check_mark', + 'lock', + 'paperclip', + 'book', + 'speaking_head_in_silhouette', + 'arrow_right', + 'loudspeaker', + 'hammer_and_wrench', + 'gear', + ], + }, +]; + +const calloutCategories = [ + 'callout', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'flags', + 'objects', + 'symbols', +]; + +export const CalloutBlock = createReactBlockSpec( + { + type: 'callout', + propSchema: { + textAlignment: defaultProps.textAlignment, + backgroundColor: defaultProps.backgroundColor, + emoji: { default: '💡' }, + }, + content: 'inline', + }, + { + render: ({ block, editor, contentRef }) => { + const [openEmojiPicker, setOpenEmojiPicker] = useState(false); + + const toggleEmojiPicker = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setOpenEmojiPicker(!openEmojiPicker); + }; + + const onClickOutside = () => setOpenEmojiPicker(false); + + const onEmojiSelect = ({ native }: { native: string }) => { + editor.updateBlock(block, { props: { emoji: native } }); + setOpenEmojiPicker(false); + }; + + // Temporary: sets a yellow background color to a callout block when added by + // the user, while keeping the colors menu on the drag handler usable for + // this custom block. + useEffect(() => { + if ( + !block.content.length && + block.props.backgroundColor === 'default' + ) { + editor.updateBlock(block, { props: { backgroundColor: 'yellow' } }); + } + }, [block, editor]); + + return ( + + + {block.props.emoji} + + + {openEmojiPicker && ( + + )} + + + ); + }, + }, +); + +export const getCalloutReactSlashMenuItems = ( + editor: DocsBlockNoteEditor, + t: TFunction<'translation', undefined>, + group: string, +) => [ + { + title: t('Callout'), + onItemClick: () => { + insertOrUpdateBlock(editor, { + type: 'callout', + }); + }, + aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'], + group, + icon: , + subtext: t('Add a callout block'), + }, +]; + +export const getCalloutFormattingToolbarItems = ( + t: TFunction<'translation', undefined>, +): BlockTypeSelectItem => ({ + name: t('Callout'), + type: 'callout', + icon: () => , + isSelected: (block) => block.type === 'callout', +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DividerBlock.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DividerBlock.tsx new file mode 100644 index 00000000..9d402a82 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DividerBlock.tsx @@ -0,0 +1,51 @@ +import { insertOrUpdateBlock } from '@blocknote/core'; +import { createReactBlockSpec } from '@blocknote/react'; +import { TFunction } from 'i18next'; + +import { Box, Icon } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { DocsBlockNoteEditor } from '../../types'; + +export const DividerBlock = createReactBlockSpec( + { + type: 'divider', + propSchema: {}, + content: 'none', + }, + { + render: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { colorsTokens } = useCunninghamTheme(); + + return ( + + ); + }, + }, +); + +export const getDividerReactSlashMenuItems = ( + editor: DocsBlockNoteEditor, + t: TFunction<'translation', undefined>, + group: string, +) => [ + { + title: t('Divider'), + onItemClick: () => { + insertOrUpdateBlock(editor, { + type: 'divider', + }); + }, + aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'], + group, + icon: , + subtext: t('Add a horizontal line'), + }, +]; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts new file mode 100644 index 00000000..34a8c459 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts @@ -0,0 +1,2 @@ +export * from './CalloutBlock'; +export * from './DividerBlock'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/index.ts new file mode 100644 index 00000000..643b57fa --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/components/index.ts @@ -0,0 +1,2 @@ +export * from './DocEditor'; +export * from './custom-blocks/'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx new file mode 100644 index 00000000..0a20001d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx @@ -0,0 +1,185 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { useRouter } from 'next/router'; +import * as Y from 'yjs'; + +import { AppWrapper } from '@/tests/utils'; + +import useSaveDoc from '../useSaveDoc'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('@/docs/doc-versioning', () => ({ + KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions', +})); + +jest.mock('@/docs/doc-management', () => ({ + useUpdateDoc: jest.requireActual('@/docs/doc-management/api/useUpdateDoc') + .useUpdateDoc, +})); + +describe('useSaveDoc', () => { + const mockRouterEvents = { + on: jest.fn(), + off: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.restore(); + + (useRouter as jest.Mock).mockReturnValue({ + events: mockRouterEvents, + }); + }); + + it('should setup event listeners on mount', () => { + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + + renderHook(() => useSaveDoc(docId, yDoc, true), { + wrapper: AppWrapper, + }); + + // Verify router event listeners are set up + expect(mockRouterEvents.on).toHaveBeenCalledWith( + 'routeChangeStart', + expect.any(Function), + ); + + // Verify window event listener is set up + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'beforeunload', + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); + }); + + it('should not save when canSave is false', async () => { + jest.useFakeTimers(); + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + + fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', { + body: JSON.stringify({ + id: 'test-doc-id', + content: 'test-content', + title: 'test-title', + }), + }); + + renderHook(() => useSaveDoc(docId, yDoc, false), { + wrapper: AppWrapper, + }); + + act(() => { + // Trigger a local update + yDoc.getMap('test').set('key', 'value'); + }); + + act(() => { + // Now advance timers after state has updated + jest.advanceTimersByTime(61000); + }); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(0); + }); + + jest.useRealTimers(); + }); + + it('should save when there are local changes', async () => { + jest.useFakeTimers(); + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + + fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', { + body: JSON.stringify({ + id: 'test-doc-id', + content: 'test-content', + title: 'test-title', + }), + }); + + renderHook(() => useSaveDoc(docId, yDoc, true), { + wrapper: AppWrapper, + }); + + act(() => { + // Trigger a local update + yDoc.getMap('test').set('key', 'value'); + }); + + act(() => { + // Now advance timers after state has updated + jest.advanceTimersByTime(61000); + }); + + await waitFor(() => { + expect(fetchMock.lastCall()?.[0]).toBe( + 'http://test.jest/api/v1.0/documents/test-doc-id/', + ); + }); + + jest.useRealTimers(); + }); + + it('should not save when there are no local changes', async () => { + jest.useFakeTimers(); + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + + fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', { + body: JSON.stringify({ + id: 'test-doc-id', + content: 'test-content', + title: 'test-title', + }), + }); + + renderHook(() => useSaveDoc(docId, yDoc, true), { + wrapper: AppWrapper, + }); + + act(() => { + // Now advance timers after state has updated + jest.advanceTimersByTime(61000); + }); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(0); + }); + + jest.useRealTimers(); + }); + + it('should cleanup event listeners on unmount', () => { + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), { + wrapper: AppWrapper, + }); + + unmount(); + + // Verify router event listeners are cleaned up + expect(mockRouterEvents.off).toHaveBeenCalledWith( + 'routeChangeStart', + expect.any(Function), + ); + + // Verify window event listener is cleaned up + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'beforeunload', + expect.any(Function), + ); + }); +}); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts new file mode 100644 index 00000000..3934dfa2 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts @@ -0,0 +1,3 @@ +export * from './useHeadings'; +export * from './useSaveDoc'; +export * from './useUploadFile'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx new file mode 100644 index 00000000..8b88eb3d --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { useHeadingStore } from '../stores'; +import { DocsBlockNoteEditor } from '../types'; + +export const useHeadings = (editor: DocsBlockNoteEditor) => { + const { setHeadings, resetHeadings } = useHeadingStore(); + + useEffect(() => { + setHeadings(editor); + + editor?.onEditorContentChange(() => { + setHeadings(editor); + }); + + return () => { + resetHeadings(); + }; + }, [editor, resetHeadings, setHeadings]); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx new file mode 100644 index 00000000..274adcff --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -0,0 +1,96 @@ +import { useRouter } from 'next/router'; +import { useCallback, useEffect, useState } from 'react'; +import * as Y from 'yjs'; + +import { useUpdateDoc } from '@/docs/doc-management/'; +import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning'; +import { isFirefox } from '@/utils/userAgent'; + +import { toBase64 } from '../utils'; + +const SAVE_INTERVAL = 60000; + +const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { + const { mutate: updateDoc } = useUpdateDoc({ + listInvalideQueries: [KEY_LIST_DOC_VERSIONS], + onSuccess: () => { + setIsLocalChange(false); + }, + }); + const [isLocalChange, setIsLocalChange] = useState(false); + + /** + * Update initial doc when doc is updated by other users, + * so only the user typing will trigger the save. + * This is to avoid saving the same doc multiple time. + */ + useEffect(() => { + const onUpdate = ( + _uintArray: Uint8Array, + _pluginKey: string, + _updatedDoc: Y.Doc, + transaction: Y.Transaction, + ) => { + setIsLocalChange(transaction.local); + }; + + yDoc.on('update', onUpdate); + + return () => { + yDoc.off('update', onUpdate); + }; + }, [yDoc]); + + const saveDoc = useCallback(() => { + if (!canSave || !isLocalChange) { + return false; + } + + updateDoc({ + id: docId, + content: toBase64(Y.encodeStateAsUpdate(yDoc)), + }); + + return true; + }, [canSave, yDoc, docId, isLocalChange, updateDoc]); + + const router = useRouter(); + + useEffect(() => { + const onSave = (e?: Event) => { + const isSaving = saveDoc(); + + /** + * Firefox does not trigger the request every time the user leaves the page. + * Plus the request is not intercepted by the service worker. + * So we prevent the default behavior to have the popup asking the user + * if he wants to leave the page, by adding the popup, we let the time to the + * request to be sent, and intercepted by the service worker (for the offline part). + */ + if ( + isSaving && + typeof e !== 'undefined' && + e.preventDefault && + isFirefox() + ) { + e.preventDefault(); + } + }; + + // Save every minute + const timeout = setInterval(onSave, SAVE_INTERVAL); + // Save when the user leaves the page + addEventListener('beforeunload', onSave); + // Save when the user navigates to another page + router.events.on('routeChangeStart', onSave); + + return () => { + clearInterval(timeout); + + removeEventListener('beforeunload', onSave); + router.events.off('routeChangeStart', onSave); + }; + }, [router.events, saveDoc]); +}; + +export default useSaveDoc; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useUploadFile.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useUploadFile.tsx new file mode 100644 index 00000000..9ba2b483 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useUploadFile.tsx @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { backendUrl } from '@/api'; +import { useMediaUrl } from '@/core/config'; +import { sleep } from '@/utils'; + +import { checkDocMediaStatus, useCreateDocAttachment } from '../api'; +import Loader from '../assets/loader.svg?url'; +import Warning from '../assets/warning.svg?url'; +import { DocsBlockNoteEditor } from '../types'; + +/** + * Upload file can be analyzed on the server side, + * we had this function to wait for the analysis to be done + * before returning the file url. It will keep the loader + * on the upload button until the analysis is done. + * @param url + * @returns Promise status_code + * @description Waits for the upload to be analyzed by checking the status of the file. + */ +const loopCheckDocMediaStatus = async (url: string) => { + const SLEEP_TIME = 5000; + const response = await checkDocMediaStatus({ + urlMedia: url, + }); + + if (response.status === 'ready') { + return response; + } else { + await sleep(SLEEP_TIME); + return await loopCheckDocMediaStatus(url); + } +}; + +const informationStatus = (src: string, text: string) => { + const loadingContainer = document.createElement('div'); + loadingContainer.style.display = 'flex'; + loadingContainer.style.alignItems = 'center'; + loadingContainer.style.justifyContent = 'left'; + loadingContainer.style.padding = '10px'; + loadingContainer.style.color = '#666'; + loadingContainer.className = + 'bn-visual-media bn-audio bn-file-name-with-icon'; + + // Create an image element for the SVG + const imgElement = document.createElement('img'); + imgElement.src = src; + + // Create a text span + const textSpan = document.createElement('span'); + textSpan.textContent = text; + textSpan.style.marginLeft = '8px'; + textSpan.style.verticalAlign = 'middle'; + imgElement.style.animation = 'spin 1.5s linear infinite'; + + // Add the spinner and text to the container + loadingContainer.appendChild(imgElement); + loadingContainer.appendChild(textSpan); + + return loadingContainer; +}; + +const replaceUploadContent = (blockId: string, elementReplace: HTMLElement) => { + const blockEl = document.body.querySelector( + `.bn-block[data-id="${blockId}"]`, + ); + + blockEl + ?.querySelector('.bn-visual-media-wrapper .bn-visual-media') + ?.replaceWith(elementReplace); + + blockEl + ?.querySelector('.bn-file-block-content-wrapper .bn-audio') + ?.replaceWith(elementReplace); + + blockEl + ?.querySelector('.bn-file-block-content-wrapper .bn-file-name-with-icon') + ?.replaceWith(elementReplace); +}; + +export const useUploadFile = (docId: string) => { + const { + mutateAsync: createDocAttachment, + isError: isErrorAttachment, + error: errorAttachment, + } = useCreateDocAttachment(); + + const uploadFile = useCallback( + async (file: File) => { + const body = new FormData(); + body.append('file', file); + + const ret = await createDocAttachment({ + docId, + body, + }); + + return `${backendUrl()}${ret.file}`; + }, + [createDocAttachment, docId], + ); + + return { + uploadFile, + isErrorAttachment, + errorAttachment, + }; +}; + +export const useUploadStatus = (editor: DocsBlockNoteEditor) => { + const ANALYZE_URL = 'media-check'; + const { t } = useTranslation(); + const mediaUrl = useMediaUrl(); + const timeoutIds = useRef>({}); + + const blockAnalyzeProcess = useCallback( + (editor: DocsBlockNoteEditor, blockId: string, url: string) => { + if (timeoutIds.current[url]) { + clearTimeout(timeoutIds.current[url]); + } + + // Delay to let the time to the dom to be rendered + const timoutId = setTimeout(() => { + replaceUploadContent( + blockId, + informationStatus(Loader.src, t('Analyzing file...')), + ); + + loopCheckDocMediaStatus(url) + .then((response) => { + const block = editor.getBlock(blockId); + if (!block) { + return; + } + + block.props = { + ...block.props, + url: `${mediaUrl}${response.file}`, + }; + + editor.updateBlock(blockId, block); + }) + .catch((error) => { + console.error('Error analyzing file:', error); + + replaceUploadContent( + blockId, + informationStatus( + Warning.src, + t('The antivirus has detected an anomaly in your file.'), + ), + ); + }); + }, 250); + + timeoutIds.current[url] = timoutId; + }, + [t, mediaUrl], + ); + + useEffect(() => { + const blocksAnalyze = editor?.document.filter( + (block) => 'url' in block.props && block.props.url.includes(ANALYZE_URL), + ); + + if (!blocksAnalyze?.length) { + return; + } + + blocksAnalyze.forEach((block) => { + if (!('url' in block.props)) { + return; + } + + blockAnalyzeProcess(editor, block.id, block.props.url); + }); + }, [blockAnalyzeProcess, editor]); + + useEffect(() => { + editor.onChange((_, context) => { + const blocksChanges = context.getChanges(); + + if (!blocksChanges.length) { + return; + } + + const blockChanges = blocksChanges[0]; + + if ( + blockChanges.source.type !== 'local' || + blockChanges.type !== 'update' || + !('url' in blockChanges.block.props) || + ('url' in blockChanges.block.props && + !blockChanges.block.props.url.includes(ANALYZE_URL)) + ) { + return; + } + + blockAnalyzeProcess( + editor, + blockChanges.block.id, + blockChanges.block.props.url, + ); + }); + }, [blockAnalyzeProcess, mediaUrl, editor, t]); +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/index.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/index.tsx new file mode 100644 index 00000000..ad4eaebf --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/index.tsx @@ -0,0 +1,4 @@ +export * from './components'; +export * from './stores'; +export * from './types'; +export * from './utils'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/index.ts new file mode 100644 index 00000000..499405d4 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/index.ts @@ -0,0 +1,3 @@ +export * from './useEditorStore'; +export * from './useHeadingStore'; +export * from './usePanelEditorStore'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useEditorStore.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useEditorStore.tsx new file mode 100644 index 00000000..9a846b37 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useEditorStore.tsx @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +import { DocsBlockNoteEditor } from '../types'; + +export interface UseEditorstore { + editor?: DocsBlockNoteEditor; + setEditor: (editor: DocsBlockNoteEditor | undefined) => void; +} + +export const useEditorStore = create((set) => ({ + editor: undefined, + setEditor: (editor) => { + set({ editor }); + }, +})); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useHeadingStore.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useHeadingStore.tsx new file mode 100644 index 00000000..c77ac3cf --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useHeadingStore.tsx @@ -0,0 +1,45 @@ +import _ from 'lodash'; +import { create } from 'zustand'; + +import { DocsBlockNoteEditor, HeadingBlock } from '../types'; + +const recursiveTextContent = (content: HeadingBlock['content']): string => { + if (!content) { + return ''; + } + + return content.reduce((acc, content) => { + if (content.type === 'text') { + return acc + content.text; + } else if (content.type === 'link') { + return acc + recursiveTextContent(content.content); + } + + return acc; + }, ''); +}; + +export interface UseHeadingStore { + headings: HeadingBlock[]; + setHeadings: (editor: DocsBlockNoteEditor) => void; + resetHeadings: () => void; +} + +export const useHeadingStore = create((set, get) => ({ + headings: [], + setHeadings: (editor) => { + const headingBlocks = editor?.document + .filter((block) => block.type === 'heading') + .map((block) => ({ + ...block, + contentText: recursiveTextContent( + block.content as unknown as HeadingBlock['content'], + ), + })) as unknown as HeadingBlock[]; + + if (!_.isEqual(get().headings, headingBlocks)) { + set(() => ({ headings: headingBlocks })); + } + }, + resetHeadings: () => set(() => ({ headings: [] })), +})); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/usePanelEditorStore.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/usePanelEditorStore.tsx new file mode 100644 index 00000000..64833d03 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/stores/usePanelEditorStore.tsx @@ -0,0 +1,19 @@ +import { create } from 'zustand'; + +export interface UsePanelEditorStore { + isPanelOpen: boolean; + setIsPanelOpen: (isOpen: boolean) => void; + isPanelTableContentOpen: boolean; + setIsPanelTableContentOpen: (isOpen: boolean) => void; +} + +export const usePanelEditorStore = create((set) => ({ + isPanelOpen: false, + isPanelTableContentOpen: true, + setIsPanelTableContentOpen: (isPanelTableContentOpen) => { + set(() => ({ isPanelTableContentOpen })); + }, + setIsPanelOpen: (isPanelOpen) => { + set(() => ({ isPanelOpen })); + }, +})); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx new file mode 100644 index 00000000..da02458e --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -0,0 +1,165 @@ +import { css } from 'styled-components'; + +export const cssEditor = (readonly: boolean) => css` + &, + & > .bn-container, + & .ProseMirror { + height: 100%; + + img.bn-visual-media[src*='-unsafe'] { + pointer-events: none; + } + + .collaboration-cursor-custom__base { + position: relative; + } + .collaboration-cursor-custom__caret { + position: absolute; + height: 100%; + width: 2px; + bottom: 4%; + left: -1px; + } + .collaboration-cursor-custom__label { + color: #0d0d0d; + font-size: 12px; + font-weight: 600; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + position: absolute; + top: -17px; + left: 0px; + padding: 0px 6px; + border-radius: 0px; + white-space: nowrap; + transition: clip-path 0.3s ease-in-out; + border-radius: 4px 4px 4px 0; + box-shadow: inset -2px 2px 6px #ffffff00; + clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%); + } + .collaboration-cursor-custom__base[data-active] + .collaboration-cursor-custom__label { + pointer-events: none; + box-shadow: inset -2px 2px 6px #ffffff88; + clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%); + } + + /** + * Side menu + */ + .bn-side-menu[data-block-type='heading'][data-level='1'] { + height: 50px; + } + .bn-side-menu[data-block-type='heading'][data-level='2'] { + height: 43px; + } + .bn-side-menu[data-block-type='heading'][data-level='3'] { + height: 35px; + } + .bn-side-menu[data-block-type='divider'] { + height: 38px; + } + + /** + * Callout, Paragraph and Heading blocks + */ + .bn-block { + border-radius: var(--c--theme--spacings--3xs); + } + + .bn-block-outer { + border-radius: var(--c--theme--spacings--3xs); + } + + .bn-block-content[data-content-type='paragraph'], + .bn-block-content[data-content-type='heading'] { + padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs); + border-radius: var(--c--theme--spacings--3xs); + } + + h1 { + font-size: 1.875rem; + } + h2 { + font-size: 1.5rem; + } + h3 { + font-size: 1.25rem; + } + a { + color: var(--c--theme--colors--greyscale-500); + cursor: pointer; + } + .bn-block-group + .bn-block-group + .bn-block-outer:not([data-prev-depth-changed]):before { + border-left: none; + } + } + + & .bn-editor { + color: var(--c--theme--colors--greyscale-700); + + /** + * Quotes + */ + blockquote { + border-left: 4px solid var(--c--theme--colors--greyscale-300); + font-style: italic; + } + } + + & .bn-block-outer:not(:first-child) { + &:has(h1) { + margin-top: 32px; + } + &:has(h2) { + margin-top: 24px; + } + &:has(h3) { + margin-top: 16px; + } + } + + & .bn-inline-content code { + background-color: gainsboro; + padding: 2px; + border-radius: 4px; + } + + @media screen and (width <= 768px) { + & .bn-editor { + padding-right: 36px; + } + } + + @media screen and (width <= 560px) { + & .bn-editor { + ${readonly && `padding-left: 10px;`} + padding-right: 10px; + } + .bn-side-menu[data-block-type='heading'][data-level='1'] { + height: 46px; + } + .bn-side-menu[data-block-type='heading'][data-level='2'] { + height: 40px; + } + .bn-side-menu[data-block-type='heading'][data-level='3'] { + height: 40px; + } + & .bn-editor h1 { + font-size: 1.6rem; + } + & .bn-editor h2 { + font-size: 1.35rem; + } + & .bn-editor h3 { + font-size: 1.2rem; + } + .bn-block-content[data-is-empty-and-focused][data-content-type='paragraph'] + .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before { + font-size: 14px; + } + } +`; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/types.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/types.tsx new file mode 100644 index 00000000..577c0176 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/types.tsx @@ -0,0 +1,28 @@ +import { BlockNoteEditor } from '@blocknote/core'; + +import { blockNoteSchema } from './components/BlockNoteEditor'; + +export interface DocAttachment { + file: string; +} + +export type HeadingBlock = { + id: string; + type: string; + text: string; + content: HeadingBlock[]; + contentText: string; + props: { + level: number; + }; +}; + +export type DocsBlockSchema = typeof blockNoteSchema.blockSchema; +export type DocsInlineContentSchema = + typeof blockNoteSchema.inlineContentSchema; +export type DocsStyleSchema = typeof blockNoteSchema.styleSchema; +export type DocsBlockNoteEditor = BlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema +>; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts new file mode 100644 index 00000000..a3d31118 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts @@ -0,0 +1,27 @@ +export const randomColor = () => { + const randomInt = (min: number, max: number) => { + return Math.floor(Math.random() * (max - min + 1)) + min; + }; + + const h = randomInt(0, 360); // hue + const s = randomInt(42, 98); // saturation + const l = randomInt(70, 90); // lightness + + return hslToHex(h, s, l); +}; + +function hslToHex(h: number, s: number, l: number) { + l /= 100; + const a = (s * Math.min(l, 1 - l)) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; +} + +export const toBase64 = (str: Uint8Array) => + Buffer.from(str).toString('base64'); diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/exportResolveFileUrl.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/exportResolveFileUrl.tsx new file mode 100644 index 00000000..531291d1 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/exportResolveFileUrl.tsx @@ -0,0 +1,30 @@ +import { baseApiUrl } from '@/api'; +import { Doc } from '@/features/docs/doc-management'; + +export const exportCorsResolveFileUrl = async ( + docId: Doc['id'], + url: string, +) => { + let resolvedUrl = url; + // If the url is not from the same origin, better to proxy the request + // to avoid CORS issues + if (!url.includes(window.location.hostname) && !url.includes('base64')) { + resolvedUrl = `${baseApiUrl()}documents/${docId}/cors-proxy/?url=${encodeURIComponent(url)}`; + } + + return exportResolveFileUrl(resolvedUrl); +}; + +export const exportResolveFileUrl = async (url: string) => { + try { + const response = await fetch(url, { + credentials: 'include', + }); + + return response.blob(); + } catch { + console.error(`Failed to fetch image: ${url}`); + } + + return url; +}; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/index.ts b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/index.ts new file mode 100644 index 00000000..59fc0c17 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/index.ts @@ -0,0 +1 @@ +export * from './exportResolveFileUrl'; diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/useExport.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/useExport.tsx new file mode 100644 index 00000000..1be23ff5 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/useExport.tsx @@ -0,0 +1,44 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +interface CreateExportParams { + templateId: string; + body: string; + body_type: 'html' | 'markdown'; + format: 'pdf' | 'docx'; +} + +export const createExport = async ({ + templateId, + body, + body_type, + format, +}: CreateExportParams): Promise => { + const response = await fetchAPI( + `templates/${templateId}/generate-document/`, + { + method: 'POST', + body: JSON.stringify({ + body, + body_type, + format, + }), + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to export the document', + await errorCauses(response), + ); + } + + return await response.blob(); +}; + +export function useExport() { + return useMutation({ + mutationFn: createExport, + }); +} diff --git a/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/useTemplates.tsx b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/useTemplates.tsx new file mode 100644 index 00000000..984393d6 --- /dev/null +++ b/submissions/devoteam/docs/src/frontend/apps/impress/src/features/docs/doc-export/api/useTemplates.tsx @@ -0,0 +1,74 @@ +import { + DefinedInitialDataInfiniteOptions, + InfiniteData, + QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query'; + +import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; + +import { Template } from '../types'; + +export enum TemplatesOrdering { + BY_CREATED_ON = 'created_at', + BY_CREATED_ON_DESC = '-created_at', +} + +export type TemplatesParams = { + ordering: TemplatesOrdering; +}; +type TemplatesAPIParams = TemplatesParams & { + page: number; +}; + +type TemplatesResponse = APIList