From d7b21b0f2f47a69393a8ad2e93c0d3d12eda4fa8 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 7 Oct 2024 12:38:32 +0300 Subject: [PATCH 01/27] feat(task): Add task updated_at field --- docs/changelog.md | 6 ++++++ protaskinate/app.py | 8 ++++---- protaskinate/entities/task.py | 2 ++ protaskinate/repositories/task_repository.py | 12 +++++++----- protaskinate/routes/project.py | 1 + protaskinate/templates/project_task_view.html | 1 + protaskinate/templates/project_view.html | 10 +++++----- schema.sql | 14 ++++++++++++++ 8 files changed, 40 insertions(+), 14 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index ca30744..8220639 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,12 @@ ProTaskinate changelog This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Added + +- User can now view updated_at for all tasks + ## [v0.2.0-beta] - 2024-10-01 ### Added diff --git a/protaskinate/app.py b/protaskinate/app.py index d8deeee..1755d5c 100644 --- a/protaskinate/app.py +++ b/protaskinate/app.py @@ -87,10 +87,10 @@ def populate_db(): ('Project 3', 1); """ sql_tasks = """ - INSERT INTO tasks (title, status, creator_id, created_at, priority, project_id, description) VALUES - ('Task 1', 'open', 1, '2021-01-01', 'low', 1, 'Task 1 description'), - ('Task 2', 'in_progress', 1, '2021-01-02', 'high', 1, 'Task 2 description'), - ('Task 3', 'done', 1, '2021-01-03', 'very_high', 1, 'Task 3 description'); + INSERT INTO tasks (title, status, creator_id, created_at, updated_at, priority, project_id, description) VALUES + ('Task 1', 'open', 1, '2021-01-01', '2021-01-01', 'low', 1, 'Task 1 description'), + ('Task 2', 'in_progress', 1, '2021-01-02', '2021-01-02', 'high', 1, 'Task 2 description'), + ('Task 3', 'done', 1, '2021-01-03', '2021-01-03', 'very_high', 1, 'Task 3 description'); """ sql_comments = """ INSERT INTO comments (task_id, creator_id, created_at, content) VALUES diff --git a/protaskinate/entities/task.py b/protaskinate/entities/task.py index 25a5aca..58507ee 100644 --- a/protaskinate/entities/task.py +++ b/protaskinate/entities/task.py @@ -54,6 +54,7 @@ class Task: status: TaskStatus priority: TaskPriority created_at: datetime + updated_at: datetime assignee_id: Optional[int] = None deadline: Optional[datetime] = None description: Optional[str] = None @@ -67,6 +68,7 @@ def __post_init__(self): self.status = validate_enum(self.status, TaskStatus, "status") self.priority = validate_enum(self.priority, TaskPriority, "priority") validate_type(self.created_at, datetime, "created_at") + validate_type(self.updated_at, datetime, "updated_at") validate_type(self.assignee_id, int, "assignee_id", allow_none=True) validate_type(self.deadline, datetime, "deadline", allow_none=True) validate_type(self.description, str, "description", allow_none=True) diff --git a/protaskinate/repositories/task_repository.py b/protaskinate/repositories/task_repository.py index 6268535..e19a712 100644 --- a/protaskinate/repositories/task_repository.py +++ b/protaskinate/repositories/task_repository.py @@ -10,8 +10,9 @@ from protaskinate.utils.database import db AllFields = ["id", "project_id", "creator_id", "title", "status", "priority", "created_at", - "assignee_id", "deadline", "description"] -RequiredFields = ["project_id", "creator_id", "title", "status", "priority", "created_at"] + "updated_at", "assignee_id", "deadline", "description"] +RequiredFields = ["project_id", "creator_id", "title", "status", "priority", + "created_at", "updated_at"] def create_task_from_row(row) -> Task: """Helper function to create a Task entity from a database row""" @@ -22,9 +23,10 @@ def create_task_from_row(row) -> Task: status=row[4], priority=row[5], created_at=row[6], - assignee_id=row[7], - deadline=row[8], - description=row[9]) + updated_at=row[7], + assignee_id=row[8], + deadline=row[9], + description=row[10]) class TaskRepository(Repository[Task]): """Task repository for managing tasks""" diff --git a/protaskinate/routes/project.py b/protaskinate/routes/project.py index cf73115..822c05a 100644 --- a/protaskinate/routes/project.py +++ b/protaskinate/routes/project.py @@ -66,6 +66,7 @@ def project_view_route(project_id: int): priority=priority, creator_id=current_user.id, created_at=datetime.now().isoformat(), + updated_at=datetime.now().isoformat(), assignee_id=assignee_id, deadline=deadline, project_id=project_id, diff --git a/protaskinate/templates/project_task_view.html b/protaskinate/templates/project_task_view.html index 58e29c0..5aa8b32 100644 --- a/protaskinate/templates/project_task_view.html +++ b/protaskinate/templates/project_task_view.html @@ -127,6 +127,7 @@

Comments


Created At: {{ task.created_at.strftime("%d/%m/%Y") }}

+

Updated At: {{ task.updated_at.strftime("%d/%m/%Y") }}

diff --git a/protaskinate/templates/project_view.html b/protaskinate/templates/project_view.html index 84850c9..1f9e717 100644 --- a/protaskinate/templates/project_view.html +++ b/protaskinate/templates/project_view.html @@ -13,6 +13,7 @@

{{project.name}}

Assignee Deadline Created At + Updated At Created By @@ -63,11 +64,10 @@

{{project.name}}

{% endif %} - {% if task.created_at != None %} - {{ task.created_at.strftime("%d/%m/%Y") }} - {% else %} - None - {% endif %} + {{ task.created_at.strftime("%d/%m/%Y") }} + + + {{ task.updated_at.strftime("%d/%m/%Y") }} {{ users_dict[task.creator_id].username }} diff --git a/schema.sql b/schema.sql index 585f9e0..e737d1b 100644 --- a/schema.sql +++ b/schema.sql @@ -28,6 +28,7 @@ CREATE TABLE tasks ( status task_status NOT NULL, priority task_priority NOT NULL, created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, assignee_id INT REFERENCES users(id) ON DELETE SET NULL, deadline TIMESTAMP, description TEXT @@ -40,3 +41,16 @@ CREATE TABLE comments ( created_at TIMESTAMP NOT NULL, content TEXT NOT NULL ); + +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tasks_update_updated_at_trigger +BEFORE UPDATE ON tasks +FOR EACH ROW +EXECUTE FUNCTION update_updated_at(); From 3c47155db80f4e59edd659ad24a5743b79e02640 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 7 Oct 2024 12:52:47 +0300 Subject: [PATCH 02/27] feat(task): Implement updating task deadline --- docs/changelog.md | 1 + protaskinate/routes/project.py | 2 ++ protaskinate/templates/project_task_view.html | 7 ++++++- protaskinate/templates/project_view.html | 17 +++++++++++------ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 8220639..38e47f5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,7 @@ This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Added - User can now view updated_at for all tasks +- User can now update the deadline of a task ## [v0.2.0-beta] - 2024-10-01 diff --git a/protaskinate/routes/project.py b/protaskinate/routes/project.py index 822c05a..d0bc043 100644 --- a/protaskinate/routes/project.py +++ b/protaskinate/routes/project.py @@ -132,6 +132,8 @@ def project_task_edit_route(project_id: int, task_id: int): update_data["assignee_id"] = int(data["assignee_id"]) if update_data["assignee_id"] == 0: update_data["assignee_id"] = None + if "deadline" in data: + update_data["deadline"] = data["deadline"] task_service.update(task_id, project_id, **update_data) diff --git a/protaskinate/templates/project_task_view.html b/protaskinate/templates/project_task_view.html index 5aa8b32..ccd749f 100644 --- a/protaskinate/templates/project_task_view.html +++ b/protaskinate/templates/project_task_view.html @@ -120,7 +120,7 @@

Comments

Deadline

- +
@@ -151,6 +151,11 @@

Comments

this.form.submit(); }); }); + document.querySelectorAll(".task-deadline-date").forEach(function(dateElement) { + dateElement.addEventListener("change", function() { + this.form.submit(); + }); + }); document.querySelectorAll(".task-delete-form").forEach(function(formElement) { formElement.addEventListener("submit", function() { if (!confirm("Are you sure you want to delete this task?")) { diff --git a/protaskinate/templates/project_view.html b/protaskinate/templates/project_view.html index 1f9e717..8884457 100644 --- a/protaskinate/templates/project_view.html +++ b/protaskinate/templates/project_view.html @@ -56,12 +56,12 @@

{{project.name}}

- - {% if task.deadline != None %} - {{ task.deadline.strftime("%d/%m/%Y") }} - {% else %} - None - {% endif %} + +
+
+ +
+
{{ task.created_at.strftime("%d/%m/%Y") }} @@ -159,6 +159,11 @@

Create Task

this.form.submit(); }); }); + document.querySelectorAll(".task-deadline-date").forEach(function(dateElement) { + dateElement.addEventListener("change", function() { + this.form.submit(); + }); + }); document.querySelectorAll(".task-delete-form").forEach(function(formElement) { formElement.addEventListener("submit", function() { if (!confirm("Are you sure you want to delete this task?")) { From 1c31f84f77ed4da76ed574cb455e94e9088c827f Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 7 Oct 2024 12:56:34 +0300 Subject: [PATCH 03/27] docs: Add checks on implemented features --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1055951..b19cd78 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@

## Key Features: -- **Task Management**: Create, assign, and track tasks with due dates, priorities, and progress statuses. -- **Kanban Board**: Visualize and manage tasks across different stages (To Do, In Progress, Done). -- **Project Organization**: Organize tasks within specific projects for better clarity and tracking. -- **User Roles**: Assign roles (Admin/User) with different access permissions. -- **Comments and Collaboration**: Leave comments on tasks for better communication between team members. -- **Activity Log**: Track task updates and project changes in real time. -- **Visualization**: Easily create charts (E.g. burndown chart) to visualize your project. +- [x] **Task Management**: Create, assign, and track tasks with due dates, priorities, and progress statuses. +- [ ] **Kanban Board**: Visualize and manage tasks across different stages (To Do, In Progress, Done). +- [x] **Project Organization**: Organize tasks within specific projects for better clarity and tracking. +- [ ] **User Roles**: Assign roles (Admin/User) with different access permissions. +- [x] **Comments and Collaboration**: Leave comments on tasks for better communication between team members. +- [ ] **Activity Log**: Track task updates and project changes in real time. +- [ ] **Visualization**: Easily create charts (E.g. burndown chart) to visualize your project. ## Tech Stack: - **Backend**: Flask (Python) From f11239bc26e84568142a61768fd5a7886a77dce3 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 7 Oct 2024 13:53:11 +0300 Subject: [PATCH 04/27] refactor(comment): Refactor getting comments of a task --- protaskinate/entities/task.py | 5 --- protaskinate/repositories/task_repository.py | 38 ------------------- protaskinate/routes/project.py | 8 ++-- protaskinate/services/comment_service.py | 6 ++- protaskinate/services/task_service.py | 6 +-- protaskinate/templates/project_task_view.html | 2 +- 6 files changed, 14 insertions(+), 51 deletions(-) diff --git a/protaskinate/entities/task.py b/protaskinate/entities/task.py index 58507ee..443b12f 100644 --- a/protaskinate/entities/task.py +++ b/protaskinate/entities/task.py @@ -58,7 +58,6 @@ class Task: assignee_id: Optional[int] = None deadline: Optional[datetime] = None description: Optional[str] = None - comments: Optional[List[Comment]] = None def __post_init__(self): validate_type(self.id, int, "id") @@ -72,7 +71,3 @@ def __post_init__(self): validate_type(self.assignee_id, int, "assignee_id", allow_none=True) validate_type(self.deadline, datetime, "deadline", allow_none=True) validate_type(self.description, str, "description", allow_none=True) - validate_type(self.comments, list, "comments", allow_none=True) - if self.comments: - for comment in self.comments: - validate_type(comment, Comment, "comment") diff --git a/protaskinate/repositories/task_repository.py b/protaskinate/repositories/task_repository.py index e19a712..202ae87 100644 --- a/protaskinate/repositories/task_repository.py +++ b/protaskinate/repositories/task_repository.py @@ -1,13 +1,6 @@ """protaskinate/repositories/task_repository.py""" -from typing import Dict, Optional, Union - -from sqlalchemy import text - from protaskinate.entities import Task -from protaskinate.repositories.comment_repository import \ - create_comment_from_row, AllFields as CommentAllFields from protaskinate.repositories.repository import Repository -from protaskinate.utils.database import db AllFields = ["id", "project_id", "creator_id", "title", "status", "priority", "created_at", "updated_at", "assignee_id", "deadline", "description"] @@ -37,35 +30,4 @@ def __init__(self): required_fields=RequiredFields, entity_creator=create_task_from_row) - def get_join_comments(self, by_fields: Dict[str, Union[int, str]]) -> Optional[Task]: - """Get a task by fields and join comments""" - if not by_fields or any(key not in self._fields for key in by_fields): - raise ValueError("Invalid by_fields") - - where_clause = " AND ".join(f"task.{key} = :{key}" for key in by_fields) - fields = ", ".join(f"task.{field}" for field in self._fields) - fields += ", "+", ".join(f"comment.{field}" for field in CommentAllFields) - - sql = f"""SELECT {fields} - FROM {self._table_name} task - LEFT JOIN comments comment ON task.id = comment.task_id - WHERE {where_clause} - ORDER BY comment.created_at""" - - result = db.session.execute(text(sql), by_fields) - rows = result.fetchall() - - if not rows: - return None - - task = self._entity_creator(rows[0]) - task.comments = [] - for row in rows: - if row[len(self._fields)] and row[0] == task.id: - comment = create_comment_from_row(row[len(self._fields):]) - task.comments.append(comment) - - return task - - task_repository = TaskRepository() diff --git a/protaskinate/routes/project.py b/protaskinate/routes/project.py index d0bc043..8949257 100644 --- a/protaskinate/routes/project.py +++ b/protaskinate/routes/project.py @@ -103,9 +103,11 @@ def project_task_view_route(project_id: int, task_id: int): created_at=datetime.now().isoformat(), content=content) if not new_comment: flash("Failed to create comment", "error") - form.content.data = "" + else: + form.content.data = "" - task = task_service.get_by_id_and_project_with_comments(task_id, project_id) + task = task_service.get_by_id_and_project(task_id, project_id) + comments = comment_service.get_all_by_task(task_id) project = project_service.get_by_id(project_id) users_dict = {user.id: user for user in user_service.get_all()} @@ -115,7 +117,7 @@ def project_task_view_route(project_id: int, task_id: int): return redirect(url_for("project.project_view_route", project_id=project_id)) return render_template("project_task_view.html", project=project, - task=task, users_dict=users_dict, form=form) + task=task, comments=comments, users_dict=users_dict, form=form) @blueprint.route("/projects//tasks//edit", methods=["POST"]) @login_required diff --git a/protaskinate/services/comment_service.py b/protaskinate/services/comment_service.py index 190d03f..b581c06 100644 --- a/protaskinate/services/comment_service.py +++ b/protaskinate/services/comment_service.py @@ -1,6 +1,6 @@ """protaskinate/services/comment_service.py""" -from typing import Optional +from typing import List, Optional from protaskinate.entities import Comment from protaskinate.repositories import comment_repository @@ -8,6 +8,10 @@ class CommentService: """Comment service that interacts with the repository""" + def get_all_by_task(self, task_id: int) -> List[Comment]: + """Get all comments by task""" + return comment_repository.get_all({"task_id": task_id}) + def create(self, **kwargs) -> Optional[Comment]: """Create a comment""" return comment_repository.create(kwargs) diff --git a/protaskinate/services/task_service.py b/protaskinate/services/task_service.py index d2b2c93..5aa3b5d 100644 --- a/protaskinate/services/task_service.py +++ b/protaskinate/services/task_service.py @@ -15,9 +15,9 @@ def get_all_by_project(self, """Get all tasks by project""" return task_repository.get_all({"project_id": project_id}, order_by_fields, reverse) - def get_by_id_and_project_with_comments(self, task_id: int, project_id: int) -> Optional[Task]: - """Get task by ID and project with comments""" - return task_repository.get_join_comments({"id": task_id, "project_id": project_id}) + def get_by_id_and_project(self, task_id: int, project_id: int) -> Optional[Task]: + """Get task by ID and project""" + return task_repository.get({"id": task_id, "project_id": project_id}) def update(self, task_id: int, project_id: int, **kwargs) -> Optional[Task]: """Update the task""" diff --git a/protaskinate/templates/project_task_view.html b/protaskinate/templates/project_task_view.html index ccd749f..d268884 100644 --- a/protaskinate/templates/project_task_view.html +++ b/protaskinate/templates/project_task_view.html @@ -50,7 +50,7 @@

Comments

- {% for comment in task.comments %} + {% for comment in comments %}
From a961621b6755552d8ce08951b6afc24a65feb091 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Tue, 8 Oct 2024 14:41:34 +0300 Subject: [PATCH 05/27] feat(role): Implement user roles for projects --- README.md | 2 +- docs/changelog.md | 12 ++- protaskinate/app.py | 38 +++++++-- protaskinate/entities/project.py | 17 ++++ .../repositories/project_repository.py | 45 +++++++++- protaskinate/repositories/user_repository.py | 1 + protaskinate/routes/project.py | 47 +++++++---- protaskinate/services/project_service.py | 45 +++++++++- protaskinate/services/user_service.py | 12 +-- protaskinate/templates/login.html | 2 +- protaskinate/templates/macros.html | 82 +++++++++++++++++++ protaskinate/templates/project_list.html | 7 +- protaskinate/templates/project_task_view.html | 68 ++++++--------- protaskinate/templates/project_view.html | 73 +++++++---------- protaskinate/templates/register.html | 2 +- protaskinate/utils/project.py | 59 +++++++++++++ schema.sql | 11 +++ 17 files changed, 393 insertions(+), 130 deletions(-) create mode 100644 protaskinate/templates/macros.html create mode 100644 protaskinate/utils/project.py diff --git a/README.md b/README.md index b19cd78..f752210 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ - [x] **Task Management**: Create, assign, and track tasks with due dates, priorities, and progress statuses. - [ ] **Kanban Board**: Visualize and manage tasks across different stages (To Do, In Progress, Done). - [x] **Project Organization**: Organize tasks within specific projects for better clarity and tracking. -- [ ] **User Roles**: Assign roles (Admin/User) with different access permissions. +- [x] **User Roles**: Assign roles (Admin/User) with different access permissions. - [x] **Comments and Collaboration**: Leave comments on tasks for better communication between team members. - [ ] **Activity Log**: Track task updates and project changes in real time. - [ ] **Visualization**: Easily create charts (E.g. burndown chart) to visualize your project. diff --git a/docs/changelog.md b/docs/changelog.md index 38e47f5..7b55f8e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,8 +8,16 @@ This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Added -- User can now view updated_at for all tasks -- User can now update the deadline of a task +- User can now have one of the following roles for each project: `reader`, `writer`, `admin` + - `reader` can view tasks, view project and comment to a task + - `writer` can also create tasks and update/delete his own tasks + - `admin` can also update/delete all tasks and delete/update the project +- User can now view updated_at for all tasks he has access to +- User can now update the deadline of a task he has access to + +### Fixed + +- Important buttons are now purple to fit the color scheme ## [v0.2.0-beta] - 2024-10-01 diff --git a/protaskinate/app.py b/protaskinate/app.py index 1755d5c..d3eebd9 100644 --- a/protaskinate/app.py +++ b/protaskinate/app.py @@ -78,7 +78,9 @@ def populate_db(): """ Populate the database with sample data """ logging.info("Populating database") sql_users = """ - INSERT INTO users (username, password) VALUES (:username, :password); + INSERT INTO users (username, password) VALUES + ('admin', :admin_password), + ('user', :user_password); """ sql_projects = """ INSERT INTO projects (name, creator_id) VALUES @@ -88,21 +90,41 @@ def populate_db(): """ sql_tasks = """ INSERT INTO tasks (title, status, creator_id, created_at, updated_at, priority, project_id, description) VALUES - ('Task 1', 'open', 1, '2021-01-01', '2021-01-01', 'low', 1, 'Task 1 description'), - ('Task 2', 'in_progress', 1, '2021-01-02', '2021-01-02', 'high', 1, 'Task 2 description'), - ('Task 3', 'done', 1, '2021-01-03', '2021-01-03', 'very_high', 1, 'Task 3 description'); + ('Project 1 Task 1', 'open', 1, '2021-01-01', '2021-01-01', 'low', 1, 'Task 1 description'), + ('Project 1 Task 2', 'in_progress', 1, '2021-01-02', '2021-01-02', 'high', 1, 'Task 2 description'), + ('Project 1 Task 3', 'done', 2, '2021-01-03', '2021-01-03', 'very_high', 1, 'Task 3 description'), + + ('Project 2 Task 1', 'open', 1, '2021-01-01', '2021-01-01', 'low', 2, 'Task 1 description'), + ('Project 2 Task 2', 'in_progress', 1, '2021-01-02', '2021-01-02', 'high', 2, 'Task 2 description'), + ('Project 2 Task 3', 'done', 2, '2021-01-03', '2021-01-03', 'very_high', 2, 'Task 3 description'), + + ('Project 3 Task 1', 'open', 1, '2021-01-01', '2021-01-01', 'low', 3, 'Task 1 description'), + ('Project 3 Task 2', 'in_progress', 1, '2021-01-02', '2021-01-02', 'high', 3, 'Task 2 description'), + ('Project 3 Task 3', 'done', 2, '2021-01-03', '2021-01-03', 'very_high', 3, 'Task 3 description'); """ sql_comments = """ INSERT INTO comments (task_id, creator_id, created_at, content) VALUES (1, 1, '2021-01-01', 'Comment 1'), - (1, 1, '2021-01-02', 'Comment 2'), - (2, 1, '2021-01-03', 'Comment 3'); + (1, 2, '2021-01-02', 'Comment 2'), + (1, 1, '2021-01-03', 'Comment 4'), + (2, 1, '2021-01-04', 'Comment 3'); """ + sql_user_projects = """ + INSERT INTO user_projects (user_id, project_id, role) VALUES + (1, 1, 'admin'), + (1, 2, 'admin'), + (1, 3, 'admin'), + (2, 2, 'reader'), + (2, 3, 'writer'); + """ + with db.engine.connect() as conn: - conn.execute(text(sql_users), - {"username": "admin", "password": generate_password_hash("admin")}) + conn.execute(text(sql_users), { + "admin_password": generate_password_hash("admin"), + "user_password": generate_password_hash("user")}) conn.execute(text(sql_projects)) conn.execute(text(sql_tasks)) conn.execute(text(sql_comments)) + conn.execute(text(sql_user_projects)) conn.commit() diff --git a/protaskinate/entities/project.py b/protaskinate/entities/project.py index 1c9ec1f..e055077 100644 --- a/protaskinate/entities/project.py +++ b/protaskinate/entities/project.py @@ -1,7 +1,16 @@ """protaskinate/entities/project.py""" from dataclasses import dataclass +from enum import Enum +from protaskinate.utils.validation import validate_enum + + +class ProjectRole(Enum): + """Enumeration representing the role of a user in a project""" + READER = "reader" + WRITER = "writer" + ADMIN = "admin" @dataclass class Project: @@ -19,3 +28,11 @@ def __post_init__(self): if not isinstance(self.creator_id, int): raise ValueError(f"Invalid creator_id: {self.creator_id}") + +@dataclass +class ProjectWithRole(Project): + """Class representing a project with the current user's role""" + role: ProjectRole + + def __post_init__(self): + self.role = validate_enum(self.role, ProjectRole, "role") diff --git a/protaskinate/repositories/project_repository.py b/protaskinate/repositories/project_repository.py index 493c76e..a22b5a5 100644 --- a/protaskinate/repositories/project_repository.py +++ b/protaskinate/repositories/project_repository.py @@ -1,14 +1,23 @@ """protaskinate/repositories/project_repository.py""" -from protaskinate.entities import Project +from typing import Any, List, Optional + +from sqlalchemy import Row, text + +from protaskinate.entities.project import Project, ProjectRole, ProjectWithRole from protaskinate.repositories.repository import Repository +from protaskinate.utils.database import db AllFields = ["id", "name", "creator_id"] RequiredFields = ["name", "creator_id"] -def create_project_from_row(row) -> Project: +def create_project_from_row(row: Row[Any]) -> Project: """Helper function to create a Project entity from a database row""" return Project(id=row[0], name=row[1], creator_id=row[2]) +def create_project_with_role_from_row(row: Row[Any]) -> ProjectWithRole: + """Helper function to create a ProjectWithRole entity from a database row""" + return ProjectWithRole(id=row[0], name=row[1], creator_id=row[2], role=row[3]) + class ProjectRepository(Repository[Project]): """Task repository for managing projects""" @@ -18,4 +27,36 @@ def __init__(self): required_fields=RequiredFields, entity_creator=create_project_from_row) + def get_all_by_user_and_roles_with_role(self, user_id: int, + roles: List[ProjectRole] + ) -> List[ProjectWithRole]: + """Get all projects and roles of a single user filtered by roles""" + fields = ", ".join(f"project.{field}" for field in self._fields) + roles_tuple = tuple(role.value for role in roles) + + sql = f"""SELECT {fields}, up.role + FROM {self._table_name} project + JOIN user_projects up ON project.id = up.project_id + WHERE up.user_id = :user_id AND up.role IN :roles""" + + result = db.session.execute(text(sql), {"user_id": user_id, "roles": roles_tuple}) + rows = result.fetchall() + + return [create_project_with_role_from_row(row) for row in rows] + + def get_user_role(self, user_id: int, project_id: int) -> Optional[ProjectRole]: + """Get the role of a user in a project""" + + sql = f"""SELECT up.role + FROM {self._table_name} project + JOIN user_projects up ON project.id = up.project_id + WHERE up.user_id = :user_id AND project.id = :project_id""" + + result = db.session.execute(text(sql), {"user_id": user_id, "project_id": project_id}) + row = result.fetchone() + + if row is None: + return None + return ProjectRole(row[0]) + project_repository = ProjectRepository() diff --git a/protaskinate/repositories/user_repository.py b/protaskinate/repositories/user_repository.py index 94c2f7b..7cfc715 100644 --- a/protaskinate/repositories/user_repository.py +++ b/protaskinate/repositories/user_repository.py @@ -1,6 +1,7 @@ """protaskinate/repositories/user_repository.py""" from sqlalchemy import text from werkzeug.security import check_password_hash + from protaskinate.entities import User from protaskinate.repositories.repository import Repository from protaskinate.utils.database import db diff --git a/protaskinate/routes/project.py b/protaskinate/routes/project.py index 8949257..228912f 100644 --- a/protaskinate/routes/project.py +++ b/protaskinate/routes/project.py @@ -12,10 +12,12 @@ from protaskinate.entities.task import TaskPriority, TaskStatus from protaskinate.services import (comment_service, project_service, task_service, user_service) +from protaskinate.utils.project import (project_read_access_required, + project_update_access_required, + task_update_access_required) blueprint = Blueprint("project", __name__) - class CreateTaskForm(FlaskForm): """Form for creating a task""" title = StringField("Title", validators=[DataRequired()]) @@ -46,11 +48,12 @@ class CreateCommentForm(FlaskForm): @login_required def project_list_route(): """Render the projects page""" - projects = project_service.get_all() - return render_template("project_list.html", projects=projects) + projects_with_roles = project_service.get_all_by_user_with_role(current_user.id) + return render_template("project_list.html", projects_with_roles=projects_with_roles) @blueprint.route("/projects/", methods=["GET", "POST"]) @login_required +@project_read_access_required def project_view_route(project_id: int): """Render the single project view page""" form = CreateTaskForm(request.form) @@ -61,32 +64,41 @@ def project_view_route(project_id: int): assignee_id = form.assignee_id.data if form.assignee_id.data != 0 else None deadline = form.deadline.data.isoformat() if form.deadline.data else None description = form.description.data if form.description.data else None - task_service.create(title=title, - status=status, - priority=priority, - creator_id=current_user.id, - created_at=datetime.now().isoformat(), - updated_at=datetime.now().isoformat(), - assignee_id=assignee_id, - deadline=deadline, - project_id=project_id, - description=description) - return redirect(request.url) + if not project_service.check_user_write_access(current_user.id, project_id): + flash("You do not have write-access to this project", "error") + else: + task_service.create(title=title, + status=status, + priority=priority, + creator_id=current_user.id, + created_at=datetime.now().isoformat(), + updated_at=datetime.now().isoformat(), + assignee_id=assignee_id, + deadline=deadline, + project_id=project_id, + description=description) + form.title.data = "" + form.assignee_id.data = 0 + form.deadline.data = None + form.description.data = "" project = project_service.get_by_id(project_id) tasks = task_service.get_all_by_project(project_id, order_by_fields=["priority", "created_at"], reverse=[True, False]) + user_project_role = project_service.get_user_role(current_user.id, project_id) users_dict = {user.id: user for user in user_service.get_all()} return render_template("project_view.html", form=form, project=project, + user_project_role=user_project_role, tasks=tasks, users_dict=users_dict) @blueprint.route("/projects//delete", methods=["POST"]) @login_required +@project_update_access_required def project_delete_route(project_id: int): """Delete a project""" project_service.delete(project_id) @@ -94,6 +106,7 @@ def project_delete_route(project_id: int): @blueprint.route("/projects//tasks/", methods=["GET", "POST"]) @login_required +@project_read_access_required def project_task_view_route(project_id: int, task_id: int): """View of a single task in a project""" form = CreateCommentForm(request.form) @@ -109,6 +122,7 @@ def project_task_view_route(project_id: int, task_id: int): task = task_service.get_by_id_and_project(task_id, project_id) comments = comment_service.get_all_by_task(task_id) project = project_service.get_by_id(project_id) + user_project_role = project_service.get_user_role(current_user.id, project_id) users_dict = {user.id: user for user in user_service.get_all()} if not project: @@ -117,10 +131,12 @@ def project_task_view_route(project_id: int, task_id: int): return redirect(url_for("project.project_view_route", project_id=project_id)) return render_template("project_task_view.html", project=project, - task=task, comments=comments, users_dict=users_dict, form=form) + task=task, comments=comments, users_dict=users_dict, + user_project_role=user_project_role, form=form) @blueprint.route("/projects//tasks//edit", methods=["POST"]) @login_required +@task_update_access_required def project_task_edit_route(project_id: int, task_id: int): """Edit a task in a project""" data = request.form @@ -143,6 +159,7 @@ def project_task_edit_route(project_id: int, task_id: int): @blueprint.route("/projects//tasks//delete", methods=["POST"]) @login_required +@task_update_access_required def project_task_delete_route(project_id: int, task_id: int): """Delete a task from a project""" task_service.delete(task_id, project_id) diff --git a/protaskinate/services/project_service.py b/protaskinate/services/project_service.py index 53a8ab6..4b24dd1 100644 --- a/protaskinate/services/project_service.py +++ b/protaskinate/services/project_service.py @@ -1,8 +1,10 @@ """protaskinate/services/project_service.py""" from typing import List, Optional -from protaskinate.entities.project import Project + +from protaskinate.entities.project import Project, ProjectRole, ProjectWithRole from protaskinate.repositories import project_repository +from protaskinate.services import task_service class ProjectService: @@ -12,12 +14,53 @@ def get_all(self) -> List[Project]: """Get all projects""" return project_repository.get_all() + def get_all_by_user_with_role(self, user_id: int) -> List[ProjectWithRole]: + """Get all user projects with their roles""" + return project_repository.get_all_by_user_and_roles_with_role(user_id, [ + ProjectRole.READER, ProjectRole.WRITER, ProjectRole.ADMIN + ]) + def get_by_id(self, project_id: int) -> Optional[Project]: """Get project by id""" return project_repository.get({"id": project_id}) + def get_user_role(self, user_id: int, project_id: int) -> Optional[ProjectRole]: + """Get the role of a user in a project""" + return project_repository.get_user_role(user_id, project_id) + def delete(self, project_id: int) -> None: """Delete a project""" return project_repository.delete({"id": project_id}) + def check_user_read_access(self, user_id: int, project_id: int) -> bool: + """Check if a user has read-access to a project""" + return project_repository.get_user_role(user_id, project_id) in [ + ProjectRole.READER, ProjectRole.WRITER, ProjectRole.ADMIN + ] + + def check_user_write_access(self, user_id: int, project_id: int) -> bool: + """Check if a user has write-access to a project""" + return project_repository.get_user_role(user_id, project_id) in [ + ProjectRole.WRITER, ProjectRole.ADMIN + ] + + def check_user_update_access(self, user_id: int, project_id: int) -> bool: + """Check if a user has update-access to a project""" + return project_repository.get_user_role(user_id, project_id) == ProjectRole.ADMIN + + def check_user_task_update_access(self, user_id: int, project_id: int, task_id: int) -> bool: + """Check if a user has update-access to a task""" + user_project_role = project_repository.get_user_role(user_id, project_id) + if user_project_role == ProjectRole.ADMIN: + return True + + task = task_service.get_by_id_and_project(task_id, project_id) + if not task or task.project_id != project_id: + return False + + if user_project_role == ProjectRole.WRITER and task.creator_id == user_id: + return True + + return False + project_service = ProjectService() diff --git a/protaskinate/services/user_service.py b/protaskinate/services/user_service.py index fd79002..45be5fb 100644 --- a/protaskinate/services/user_service.py +++ b/protaskinate/services/user_service.py @@ -1,5 +1,4 @@ """protaskinate/services/user_service.py""" - from typing import List, Optional from werkzeug.security import generate_password_hash @@ -10,11 +9,6 @@ class UserService: """Class representing a service for users""" - def login(self, username: str, password: str) -> Optional[User]: - """Login a user""" - if user_repository.verify_password(username, password): - return user_repository.get({"username": username}) - return None def get_all(self) -> List[User]: """Get all users""" @@ -28,6 +22,12 @@ def get_by_username(self, username: str) -> Optional[User]: """Get a user by username""" return user_repository.get({"username": username}) + def login(self, username: str, password: str) -> Optional[User]: + """Login a user""" + if user_repository.verify_password(username, password): + return user_repository.get({"username": username}) + return None + def register(self, username: str, password: str) -> Optional[User]: """Register a user""" return user_repository.create({"username": username, diff --git a/protaskinate/templates/login.html b/protaskinate/templates/login.html index 62f763d..685d72b 100644 --- a/protaskinate/templates/login.html +++ b/protaskinate/templates/login.html @@ -29,7 +29,7 @@

Login

- {{ form.submit(class="button") }} + {{ form.submit(class="button is-link") }}
diff --git a/protaskinate/templates/macros.html b/protaskinate/templates/macros.html new file mode 100644 index 0000000..1aaf61f --- /dev/null +++ b/protaskinate/templates/macros.html @@ -0,0 +1,82 @@ +{% macro disable_form_if_no_task_update_access(user_project_role, task, current_user) %} + {% if user_project_role.value != "admin" and (user_project_role.value != "writer" or task.creator_id != current_user.id) %} +
+ {% endif %} +{% endmacro %} + +{% macro disable_form_if_no_write_access(user_project_role) %} + {% if user_project_role.value not in ("admin", "writer") %} +
+ {% endif %} +{% endmacro %} + +{% macro disable_form_if_no_update_access(user_project_role) %} + {% if user_project_role.value != "admin" %} +
+ {% endif %} +{% endmacro %} + +{% macro delete_project_form(project) %} + +{% endmacro %} + +{% macro update_task_status_form(project, task, user_project_role, current_user) %} +
+ {{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }} +
+
+ +
+
+
+{% endmacro %} + +{% macro update_task_priority_form(project, task, user_project_role, current_user) %} +
+ {{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }} +
+
+ +
+
+
+{% endmacro %} + +{% macro update_task_assignee_form(project, task, user_project_role, current_user, users_dict) %} +
+ {{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }} +
+
+ +
+
+
+{% endmacro %} + +{% macro update_task_deadline_form(project, task, user_project_role, current_user) %} +
+ {{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }} +
+ +
+
+{% endmacro %} diff --git a/protaskinate/templates/project_list.html b/protaskinate/templates/project_list.html index 99c4d83..2d8ce3c 100644 --- a/protaskinate/templates/project_list.html +++ b/protaskinate/templates/project_list.html @@ -1,10 +1,11 @@ +{% from "macros.html" import delete_project_form %} {% extends "base.html" %} {% block title %}Projects{% endblock %} {% block content %}

Projects

- {% for project in projects %} + {% for project in projects_with_roles %}
@@ -16,9 +17,7 @@

Projects

View - + {{ delete_project_form(project) }}
diff --git a/protaskinate/templates/project_task_view.html b/protaskinate/templates/project_task_view.html index d268884..f054b3a 100644 --- a/protaskinate/templates/project_task_view.html +++ b/protaskinate/templates/project_task_view.html @@ -1,3 +1,4 @@ +{% from "macros.html" import update_task_status_form, update_task_priority_form, update_task_assignee_form, update_task_deadline_form, disable_form_if_no_task_update_access %} {% extends "base.html" %} {% block title %}{{task.title}}{% endblock %} {% block content %} @@ -5,18 +6,25 @@

task-{{ task.id }}

-
- +
+ +
+
@@ -43,7 +51,7 @@

Comments

- {{ form.submit(class="button") }} + {{ form.submit(class="button is-link") }}
@@ -74,41 +82,15 @@

Comments

-
-
- -
-
+ {{ update_task_status_form(project, task, user_project_role, current_user) }}
-
-
- -
-
+ {{ update_task_priority_form(project, task, user_project_role, current_user) }}
-
-
- -
-
+ {{ update_task_assignee_form(project, task, user_project_role, current_user, users_dict) }}

Creator

@@ -118,11 +100,7 @@

Comments

Deadline

-
-
- -
-
+ {{ update_task_deadline_form(project, task, user_project_role, current_user) }}

diff --git a/protaskinate/templates/project_view.html b/protaskinate/templates/project_view.html index 8884457..d55f9c5 100644 --- a/protaskinate/templates/project_view.html +++ b/protaskinate/templates/project_view.html @@ -1,3 +1,4 @@ +{% from "macros.html" import update_task_status_form, update_task_priority_form, update_task_assignee_form, update_task_deadline_form, disable_form_if_no_task_update_access, disable_form_if_no_write_access %} {% extends "base.html" %} {% block title %}{{project.name}}{% endblock %} {% block content %} @@ -22,46 +23,16 @@

{{project.name}}

{{ task.title }} -
-
- -
-
+ {{ update_task_status_form(project, task, user_project_role, current_user) }} -
-
- -
-
+ {{ update_task_priority_form(project, task, user_project_role, current_user) }} -
-
- -
-
+ {{ update_task_assignee_form(project, task, user_project_role, current_user, users_dict) }} -
-
- -
-
+ {{ update_task_deadline_form(project, task, user_project_role, current_user) }} {{ task.created_at.strftime("%d/%m/%Y") }} @@ -79,6 +50,7 @@

{{project.name}}

+ {{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }}
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} -
- {% block content %}{% endblock %} +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ + {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %}
- +
+
+

{% block hero_title %}{% endblock %}

+

{% block hero_description %}{% endblock %}

+
+
+ {% block content %}{% endblock %} +
diff --git a/protaskinate/templates/dashboard.html b/protaskinate/templates/dashboard.html index ee241bd..dd8e291 100644 --- a/protaskinate/templates/dashboard.html +++ b/protaskinate/templates/dashboard.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}Dashboard{% endblock %} +{% block hero_title %}Dashboard{% endblock %} +{% block hero_description %}Hello {{current_user.username}}, welcome to the dashboard!{% endblock %} {% block content %} -

Dashboard

-

Hello {{current_user.username}}, welcome to the dashboard!

{% endblock %} diff --git a/protaskinate/templates/macros.html b/protaskinate/templates/macros.html index 1aaf61f..885946f 100644 --- a/protaskinate/templates/macros.html +++ b/protaskinate/templates/macros.html @@ -80,3 +80,7 @@
{% endmacro %} + +{% macro format_datetime(datetime) %} + +{% endmacro %} diff --git a/protaskinate/templates/project_list.html b/protaskinate/templates/project_list.html index 2d8ce3c..06a4d50 100644 --- a/protaskinate/templates/project_list.html +++ b/protaskinate/templates/project_list.html @@ -1,38 +1,42 @@ -{% from "macros.html" import delete_project_form %} +{% from "macros.html" import delete_project_form, format_datetime %} {% extends "base.html" %} {% block title %}Projects{% endblock %} +{% block hero_title %}Projects{% endblock %} +{% block hero_description%}View all projects{% endblock %} {% block content %} -

Projects

-
-
- {% for project in projects_with_roles %} -
-
-
-

{{ project.name }}

-
-
-
+
+
+
+
+ {% for project in projects_with_roles %} +
+
+
+
+

{{ project.name }}

+

{% if project.description %}{{ project.description }}{% endif %}

+
+
+
+ View + {{ delete_project_form(project) }} +
+
-
-
- View - {{ delete_project_form(project) }} -
+ {% endfor %}
-
- {% endfor %} -
-
- + {% endblock %} diff --git a/protaskinate/templates/project_task_view.html b/protaskinate/templates/project_task_view.html index f054b3a..2830de0 100644 --- a/protaskinate/templates/project_task_view.html +++ b/protaskinate/templates/project_task_view.html @@ -1,8 +1,8 @@ -{% from "macros.html" import update_task_status_form, update_task_priority_form, update_task_assignee_form, update_task_deadline_form, disable_form_if_no_task_update_access %} +{% from "macros.html" import update_task_status_form, update_task_priority_form, update_task_assignee_form, update_task_deadline_form, disable_form_if_no_task_update_access, format_datetime %} {% extends "base.html" %} {% block title %}{{task.title}}{% endblock %} {% block content %} -
+

task-{{ task.id }}

@@ -28,56 +28,60 @@
-
-
-

{{ task.title }}

-

{% if task.description != None %}{{ task.description }}{% endif %}

-
-
-

Comments

-
-
- - - -
-
-
- {{ form.hidden_tag() }} -
-
- {{ form.content(class="textarea") }} +
+
+
+

{{ task.title }}

+

{% if task.description != None %}{{ task.description }}{% endif %}

+
+
+
+
+

Comments

+
+
+ + + +
+
+ + {{ form.hidden_tag() }} +
+
+ {{ form.content(class="textarea") }} +
-
-
-
- {{ form.submit(class="button is-link") }} +
+
+ {{ form.submit(class="button is-link") }} +
-
- + +
-
-
- {% for comment in comments %} -
-
- - - -
-
-
- {{ users_dict[comment.creator_id].username }} -

{{ comment.created_at.strftime("%d/%m/%Y") }}

+
+ {% for comment in comments %} +
+
+ + +
-
-

{{ comment.content }}

+
+
+ {{ users_dict[comment.creator_id].username }} +

{{ comment.created_at.strftime("%d/%m/%Y") }}

+
+
+

{{ comment.content }}

+
-
- {% endfor %} + {% endfor %} +
-
+
@@ -104,8 +108,8 @@

Comments


-

Created At: {{ task.created_at.strftime("%d/%m/%Y") }}

-

Updated At: {{ task.updated_at.strftime("%d/%m/%Y") }}

+

Created At: {{ format_datetime(task.created_at) }}

+

Updated At: {{ format_datetime(task.updated_at) }}

diff --git a/protaskinate/templates/project_view.html b/protaskinate/templates/project_view.html index d55f9c5..3990dd7 100644 --- a/protaskinate/templates/project_view.html +++ b/protaskinate/templates/project_view.html @@ -1,161 +1,190 @@ -{% from "macros.html" import update_task_status_form, update_task_priority_form, update_task_assignee_form, update_task_deadline_form, disable_form_if_no_task_update_access, disable_form_if_no_write_access %} +{% from "macros.html" import update_task_status_form, update_task_priority_form, update_task_assignee_form, update_task_deadline_form, disable_form_if_no_task_update_access, disable_form_if_no_write_access, format_datetime %} {% extends "base.html" %} {% block title %}{{project.name}}{% endblock %} +{% block hero_title %}{{project.name}}{% endblock %} +{% block hero_description %}{% if project.description %}{{project.description}}{% endif %}{% endblock %} {% block content %} -

{{project.name}}

-
-
- - - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - - - - + + {% endfor %} - -
- {{ form.description.label(class="label") }} -
- {{ form.description(class="textarea") }} +
+
TitleStatusPriorityAssigneeDeadlineCreated AtUpdated AtCreated By
{{ task.title }} - {{ update_task_status_form(project, task, user_project_role, current_user) }} - - {{ update_task_priority_form(project, task, user_project_role, current_user) }} - - {{ update_task_assignee_form(project, task, user_project_role, current_user, users_dict) }} - - {{ update_task_deadline_form(project, task, user_project_role, current_user) }} - - {{ task.created_at.strftime("%d/%m/%Y") }} - - {{ task.updated_at.strftime("%d/%m/%Y") }} - {{ users_dict[task.creator_id].username }} - - - - - - -
- {{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }} -
+ + {{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }} + + +
+
+ +
+
+

Create Task

+
+ {{ disable_form_if_no_write_access(user_project_role) }} + {{ form.hidden_tag() }} +
+ {{ form.title.label(class="label") }} +
+ {{ form.title(class="input") }} +
+ {% for error in form.title.errors %} +

{{ error }}

+ {% endfor %}
- {% for error in form.description.errors %} -

{{ error }}

- {% endfor %} -
-
- {{ form.status.label(class="label") }} -
-
- {{ form.status(class="input") }} +
+ {{ form.description.label(class="label") }} +
+ {{ form.description(class="textarea") }}
+ {% for error in form.description.errors %} +

{{ error }}

+ {% endfor %}
-
-
- {{ form.priority.label(class="label") }} -
-
- {{ form.priority(class="input") }} +
+ {{ form.status.label(class="label") }} +
+
+ {{ form.status(class="input") }} +
-
-
- {{ form.assignee_id.label(class="label") }} -
-
- {{ form.assignee_id(class="input") }} +
+ {{ form.priority.label(class="label") }} +
+
+ {{ form.priority(class="input") }} +
-
-
- {{ form.deadline.label(class="label") }} -
- {{ form.deadline(class="input") }} +
+ {{ form.assignee_id.label(class="label") }} +
+
+ {{ form.assignee_id(class="input") }} +
+
- {% for error in form.deadline.errors %} -

{{ error }}

- {% endfor %} -
-
-
- {{ form.submit(class="button is-link") }} +
+ {{ form.deadline.label(class="label") }} +
+ {{ form.deadline(class="input") }} +
+ {% for error in form.deadline.errors %} +

{{ error }}

+ {% endfor %} +
+
+
+ {{ form.submit(class="button is-link") }} +
-
- -
-
- + {% endblock %} diff --git a/protaskinate/utils/validation.py b/protaskinate/utils/validation.py index daf74c9..dc1bd2e 100644 --- a/protaskinate/utils/validation.py +++ b/protaskinate/utils/validation.py @@ -1,7 +1,9 @@ """protaskinate/utils/validation.py""" -def validate_enum(value, enum_class, field_name): +def validate_enum(value, enum_class, field_name, allow_none=False): """Validate that a value is a member of an enumeration.""" + if allow_none and value is None: + return None try: return enum_class(value) except ValueError as exc: diff --git a/schema.sql b/schema.sql index c303110..820dd03 100644 --- a/schema.sql +++ b/schema.sql @@ -21,7 +21,9 @@ CREATE TABLE users ( CREATE TABLE projects ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, - creator_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE + creator_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL, + description TEXT ); CREATE TABLE tasks ( From d13042d7f129a6c31682d6e4051843157c688e2f Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 14 Oct 2024 09:16:27 +0300 Subject: [PATCH 07/27] fix(templates): Better layout for task and project view --- protaskinate/templates/project_task_view.html | 95 ++++++++++--------- protaskinate/templates/project_view.html | 6 +- 2 files changed, 51 insertions(+), 50 deletions(-) diff --git a/protaskinate/templates/project_task_view.html b/protaskinate/templates/project_task_view.html index 2830de0..f8c0412 100644 --- a/protaskinate/templates/project_task_view.html +++ b/protaskinate/templates/project_task_view.html @@ -5,7 +5,7 @@
-

task-{{ task.id }}

+

{{project.name}}/task-{{ task.id }}

{{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }} @@ -28,60 +28,61 @@
-
-
-
-

{{ task.title }}

-

{% if task.description != None %}{{ task.description }}{% endif %}

+
+
+

{{ task.title }}

+
+
+

Description

+
+

{{ task.description }}

-
-
-
-

Comments

-
-
- - - -
-
- - {{ form.hidden_tag() }} -
-
- {{ form.content(class="textarea") }} -
+
+
+

Comments

+
+
+ + + +
+
+ + {{ form.hidden_tag() }} +
+
+ {{ form.content(class="textarea") }}
-
-
- {{ form.submit(class="button is-link") }} -
+
+
+
+ {{ form.submit(class="button is-link") }}
- -
+
+
-
- {% for comment in comments %} -
-
- - - +
+
+ {% for comment in comments %} +
+
+ + + +
+
+
+ {{ users_dict[comment.creator_id].username }} +

{{ comment.created_at.strftime("%d/%m/%Y") }}

-
-
- {{ users_dict[comment.creator_id].username }} -

{{ comment.created_at.strftime("%d/%m/%Y") }}

-
-
-

{{ comment.content }}

-
+
+

{{ comment.content }}

- {% endfor %} -
+
+ {% endfor %}
-
+
diff --git a/protaskinate/templates/project_view.html b/protaskinate/templates/project_view.html index 3990dd7..e0b1f81 100644 --- a/protaskinate/templates/project_view.html +++ b/protaskinate/templates/project_view.html @@ -6,7 +6,7 @@ {% block content %}
-

Project Details

+

Project Details

-

Tasks

+

Tasks

@@ -93,7 +93,7 @@

Tasks

-

Create Task

+

Create Task

{{ disable_form_if_no_write_access(user_project_role) }} {{ form.hidden_tag() }} From 03acdf4c3123a20a19069a63a9fbf0c4480e9a48 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 14 Oct 2024 10:20:49 +0300 Subject: [PATCH 08/27] feat(project): Add project creation form --- protaskinate/app.py | 11 ++--- protaskinate/entities/project.py | 12 ++++-- protaskinate/entities/task.py | 3 +- .../repositories/project_repository.py | 10 ++--- protaskinate/routes/project.py | 30 +++++++++++--- protaskinate/services/project_service.py | 4 ++ protaskinate/templates/macros.html | 8 ++-- protaskinate/templates/project_list.html | 41 ++++++++++++++++--- protaskinate/templates/project_view.html | 10 ++++- protaskinate/utils/validation.py | 4 +- schema.sql | 21 ++++++++++ 11 files changed, 117 insertions(+), 37 deletions(-) diff --git a/protaskinate/app.py b/protaskinate/app.py index b4aa127..6f805f0 100644 --- a/protaskinate/app.py +++ b/protaskinate/app.py @@ -83,10 +83,10 @@ def populate_db(): ('user', :user_password); """ sql_projects = """ - INSERT INTO projects (name, creator_id, created_at, description) VALUES - ('Project 1', 1, '2021-01-01', 'Project 1 description'), - ('Project 2', 1, '2021-01-02', 'Project 2 description'), - ('Project 3', 1, '2021-01-03', 'Project 3 description'); + INSERT INTO projects (name, creator_id, created_at, updated_at, description) VALUES + ('Project 1', 1, '2021-01-01', '2021-01-03', 'Project 1 description'), + ('Project 2', 1, '2021-01-02', '2021-01-03', 'Project 2 description'), + ('Project 3', 1, '2021-01-03', '2021-01-03', 'Project 3 description'); """ sql_tasks = """ INSERT INTO tasks (title, status, creator_id, created_at, updated_at, priority, project_id, description) VALUES @@ -111,9 +111,6 @@ def populate_db(): """ sql_user_projects = """ INSERT INTO user_projects (user_id, project_id, role) VALUES - (1, 1, 'admin'), - (1, 2, 'admin'), - (1, 3, 'admin'), (2, 2, 'reader'), (2, 3, 'writer'); """ diff --git a/protaskinate/entities/project.py b/protaskinate/entities/project.py index bbb1904..71d4ed5 100644 --- a/protaskinate/entities/project.py +++ b/protaskinate/entities/project.py @@ -5,6 +5,7 @@ from enum import Enum from typing import Optional +from protaskinate.entities.user import User from protaskinate.utils.validation import validate_enum, validate_type @@ -21,6 +22,7 @@ class Project: name: str creator_id: int created_at: datetime + updated_at: datetime description: Optional[str] = None def __post_init__(self): @@ -28,13 +30,17 @@ def __post_init__(self): validate_type(self.name, str, "name") validate_type(self.creator_id, int, "creator_id") validate_type(self.created_at, datetime, "created_at") + validate_type(self.updated_at, datetime, "updated_at") validate_type(self.description, str, "description", allow_none=True) @dataclass -class ProjectWithRole(Project): +class ProjectWithRole: """Class representing a project with the current user's role""" - role: Optional[ProjectRole] = None + project: Project + role: ProjectRole def __post_init__(self): - self.role = validate_enum(self.role, ProjectRole, "role", allow_none=True) + validate_type(self.project, Project, "project") + self.role = validate_enum(self.role, ProjectRole, "role") + diff --git a/protaskinate/entities/task.py b/protaskinate/entities/task.py index 443b12f..5611cd6 100644 --- a/protaskinate/entities/task.py +++ b/protaskinate/entities/task.py @@ -3,9 +3,8 @@ from dataclasses import dataclass from datetime import datetime from enum import Enum -from typing import List, Optional +from typing import Optional -from protaskinate.entities.comment import Comment from protaskinate.utils.validation import validate_enum, validate_type diff --git a/protaskinate/repositories/project_repository.py b/protaskinate/repositories/project_repository.py index 4fb812d..6a13aa2 100644 --- a/protaskinate/repositories/project_repository.py +++ b/protaskinate/repositories/project_repository.py @@ -7,17 +7,17 @@ from protaskinate.repositories.repository import Repository from protaskinate.utils.database import db -AllFields = ["id", "name", "creator_id", "created_at", "description"] -RequiredFields = ["name", "creator_id", "created_at"] +AllFields = ["id", "name", "creator_id", "created_at", "updated_at", "description"] +RequiredFields = ["name", "creator_id", "created_at", "updated_at"] def create_project_from_row(row: Row[Any]) -> Project: """Helper function to create a Project entity from a database row""" - return Project(id=row[0], name=row[1], creator_id=row[2], created_at=row[3], description=row[4]) + return Project(id=row[0], name=row[1], creator_id=row[2], created_at=row[3], + updated_at=row[4], description=row[5]) def create_project_with_role_from_row(row: Row[Any]) -> ProjectWithRole: """Helper function to create a ProjectWithRole entity from a database row""" - return ProjectWithRole(id=row[0], name=row[1], creator_id=row[2], - created_at=row[3], description=row[4], role=row[5]) + return ProjectWithRole(project=create_project_from_row(row), role=ProjectRole(row[6])) class ProjectRepository(Repository[Project]): """Task repository for managing projects""" diff --git a/protaskinate/routes/project.py b/protaskinate/routes/project.py index 228912f..3a643c2 100644 --- a/protaskinate/routes/project.py +++ b/protaskinate/routes/project.py @@ -7,7 +7,7 @@ from flask_wtf import FlaskForm from wtforms import (DateField, SelectField, StringField, SubmitField, TextAreaField) -from wtforms.validators import DataRequired, Optional +from wtforms.validators import DataRequired, Length, Optional from protaskinate.entities.task import TaskPriority, TaskStatus from protaskinate.services import (comment_service, project_service, @@ -20,8 +20,8 @@ class CreateTaskForm(FlaskForm): """Form for creating a task""" - title = StringField("Title", validators=[DataRequired()]) - description = TextAreaField("Description", validators=[Optional()]) + title = StringField("Title", validators=[DataRequired(), Length(min=3, max=50)]) + description = TextAreaField("Description", validators=[Optional(), Length(max=500)]) status = SelectField("Status", choices=[ (status.value, status.name.lower().replace("_"," ").title()) @@ -41,15 +41,33 @@ def __init__(self, *args, **kwargs): class CreateCommentForm(FlaskForm): """Form for creating a comment""" - content = TextAreaField("Content", validators=[DataRequired()]) + content = TextAreaField("Content", validators=[DataRequired(), Length(max=500)]) submit = SubmitField("Send") -@blueprint.route("/projects", methods=["GET"]) +class CreateProjectForm(FlaskForm): + """Form for creating a project""" + name = StringField("Name", validators=[DataRequired(), Length(min=3, max=50)]) + description = TextAreaField("Description", validators=[Optional(), Length(max=500)]) + submit = SubmitField("Create Project") + +@blueprint.route("/projects", methods=["GET", "POST"]) @login_required def project_list_route(): """Render the projects page""" + form = CreateProjectForm(request.form) + if request.method == "POST" and form.validate_on_submit(): + name = form.name.data + description = form.description.data if form.description.data else None + project_service.create(name=name, creator_id=current_user.id, + created_at=datetime.now().isoformat(), + updated_at=datetime.now().isoformat(), + description=description) + form.name.data = "" + form.description.data = "" + flash("Project created successfully", "success") + projects_with_roles = project_service.get_all_by_user_with_role(current_user.id) - return render_template("project_list.html", projects_with_roles=projects_with_roles) + return render_template("project_list.html", form=form, projects_with_roles=projects_with_roles) @blueprint.route("/projects/", methods=["GET", "POST"]) @login_required diff --git a/protaskinate/services/project_service.py b/protaskinate/services/project_service.py index 4b24dd1..adbf255 100644 --- a/protaskinate/services/project_service.py +++ b/protaskinate/services/project_service.py @@ -32,6 +32,10 @@ def delete(self, project_id: int) -> None: """Delete a project""" return project_repository.delete({"id": project_id}) + def create(self, **kwargs) -> Optional[Project]: + """Create a project""" + return project_repository.create(kwargs) + def check_user_read_access(self, user_id: int, project_id: int) -> bool: """Check if a user has read-access to a project""" return project_repository.get_user_role(user_id, project_id) in [ diff --git a/protaskinate/templates/macros.html b/protaskinate/templates/macros.html index 885946f..4db65e1 100644 --- a/protaskinate/templates/macros.html +++ b/protaskinate/templates/macros.html @@ -16,10 +16,10 @@ {% endif %} {% endmacro %} -{% macro delete_project_form(project) %} - - {{ disable_form_if_no_update_access(project.role) }} - diff --git a/protaskinate/templates/project_list.html b/protaskinate/templates/project_list.html index 06a4d50..5067d7f 100644 --- a/protaskinate/templates/project_list.html +++ b/protaskinate/templates/project_list.html @@ -8,18 +8,18 @@
- {% for project in projects_with_roles %} + {% for pwr in projects_with_roles %}
-

{{ project.name }}

-

{% if project.description %}{{ project.description }}{% endif %}

+

{{ pwr.project.name }}

+

{% if pwr.project.description %}{{ pwr.project.description }}{% endif %}

- View - {{ delete_project_form(project) }} + View + {{ delete_project_form(pwr) }}
@@ -28,6 +28,37 @@

{{ project.name }}

+
+
+

Create Project

+
+ {{ form.hidden_tag() }} +
+ +
+ {{ form.name(class="input") }} +
+ {% for error in form.name.errors %} +

{{ error }}

+ {% endfor %} +
+
+ +
+ {{ form.description(class="textarea") }} +
+ {% for error in form.description.errors %} +

{{ error }}

+ {% endfor %} +
+
+
+ {{ form.submit(class="button is-link") }} +
+
+ +
+
{% endblock %} From 912b79502b3403ec18d3dbb25e2c75c2f449d555 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 14 Oct 2024 13:36:54 +0300 Subject: [PATCH 10/27] docs: Update unreleased changelog --- docs/changelog.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index e76753f..013b9dc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -14,7 +14,9 @@ This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - `admin` can also update/delete all tasks and delete/update the project - User can now view updated_at for all tasks user has access to - User can now update the deadline of a task user has access to -- User can now addditionally view description, creator, role and creation date for all projects user has access to +- User can now addditionally view description, creator, role, updated_at and created_at for all projects user has access to +- User can now assign and de-assign roles for projects user has admin access to +- User can now create a project ### Fixed From 5fa4896575769e38df4f14d4702a2db9adbb1236 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 14 Oct 2024 16:20:56 +0300 Subject: [PATCH 11/27] feat(board): Implement project kanban board --- protaskinate/routes/project.py | 10 ++ protaskinate/services/task_service.py | 4 +- protaskinate/templates/base.html | 26 ++++- protaskinate/templates/project_board.html | 120 ++++++++++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 protaskinate/templates/project_board.html diff --git a/protaskinate/routes/project.py b/protaskinate/routes/project.py index 5b6ec43..457829c 100644 --- a/protaskinate/routes/project.py +++ b/protaskinate/routes/project.py @@ -61,6 +61,16 @@ def project_view_route(project_id: int): project_users=project_users, users_dict=users_dict) +@blueprint.route("/projects//board", methods=["GET"]) +@login_required +@project_read_access_required +def project_board_route(project_id: int): + """Render the project board view page""" + project = project_service.get_by_id(project_id) + tasks = task_service.get_all_by_project(project_id) + user_project_role = project_service.get_user_role(current_user.id, project_id) + return render_template("project_board.html", project=project, tasks=tasks, user_project_role=user_project_role) + @blueprint.route("/projects//delete", methods=["POST"]) @login_required @project_update_access_required diff --git a/protaskinate/services/task_service.py b/protaskinate/services/task_service.py index 5aa3b5d..a9b03be 100644 --- a/protaskinate/services/task_service.py +++ b/protaskinate/services/task_service.py @@ -10,8 +10,8 @@ class TaskService: """Class representing a service for tasks""" def get_all_by_project(self, project_id: int, - order_by_fields: Optional[List[str]], - reverse: Optional[List[bool]]) -> List[Task]: + order_by_fields: Optional[List[str]] = None, + reverse: Optional[List[bool]] = None) -> List[Task]: """Get all tasks by project""" return task_repository.get_all({"project_id": project_id}, order_by_fields, reverse) diff --git a/protaskinate/templates/base.html b/protaskinate/templates/base.html index 900ab9d..9a30ef3 100644 --- a/protaskinate/templates/base.html +++ b/protaskinate/templates/base.html @@ -33,8 +33,30 @@

ProTaskinate

{% if current_user.is_authenticated %}
{{ disable_form_if_no_task_update_access(user_project_role, task, current_user) }} + {{ csrf_token_input() }}