diff --git a/README.md b/README.md index 1055951..0f69a1f 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. +- [x] **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. +- [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. +- [x] **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) @@ -75,6 +75,20 @@ poetry run invoke dev ## Weekly Report +### Loppupalautus - 2024-10-15 + +The project is now finished. The project has a good code structure and clean code. All features except the visualization feature have been implemented. After adding some unittests for tasks and projects, the test coverage has risen to around 60%. + +Addressing some of the feedback in labtool: +- The CSRF vulnerability has been fixed +- After a thorough code review, I believe there are no SQL injection vulnerabilities present. While I am formatting some queries, all user inputs are sanitized and passed as parameters. The formatting applied to table and field names does not involve any user input. +- I added a COUNT query to the dashboard, but I am not sure if that is enough. I couldn't think of any other aggregates that would be useful. +- I removed all HTML5 client-side validation so all the errors are displayed at once. However, I don't think disabling client-side validation is a good idea, since it will add more load to the server. + +The project is available in production on [protaskinate-page.host.ender.fi](https://protaskinate-page.host.ender.fi/). + +More info about updates in the [changelog](docs/changelog.md). + ### Välipalautus 3 - 2024-10-1 The project is almost finished. The project still has good code structure and clean code. Many more features have been implemented, but some are still left. Test coverage is still very poor. diff --git a/docs/changelog.md b/docs/changelog.md index ca30744..e02ca5b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,33 @@ ProTaskinate changelog This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.0.0] - 2024-10-15 + +### Added + +- 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 user's own tasks + - `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, 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 +- User can now view all tasks of project user has access to in a kanban board +- User can now view a project activity log if he has admin access to the project + - Activity log shows task creation, task update, task deletion, task creation and project update +- User can view the amount of tasks assigned to the user in the dashboard + +### Fixed + +- Important buttons are now purple to fit the color scheme +- Page content has been divided into logical sections +- When a project is selected, the navbar shows navigations for that project +- Forms now show errors all at once instead of one by one +- Added length restrictions to most fields +- Datetimes are now displayed according to the user's timezone + ## [v0.2.0-beta] - 2024-10-01 ### Added diff --git a/poetry.lock b/poetry.lock index 56a46fd..8272201 100644 --- a/poetry.lock +++ b/poetry.lock @@ -811,4 +811,4 @@ email = ["email-validator"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "b64b5fef417f799718d49bcddbbc685644d0d080b3de0a03e556cf51ccc7cc0d" +content-hash = "8e21a453d273ccfd623e743da2d7589cbdd68002242ba7cb3c4487b592a46365" diff --git a/protaskinate/app.py b/protaskinate/app.py index d8deeee..2966fd6 100644 --- a/protaskinate/app.py +++ b/protaskinate/app.py @@ -10,6 +10,7 @@ from werkzeug.security import generate_password_hash from protaskinate.routes import dashboard, login, logout, project, register +from protaskinate.utils.csrf import csrf from protaskinate.utils.database import db from protaskinate.utils.login_manager import lm @@ -18,15 +19,19 @@ def create_app(): - """ Create the Flask app """ + """Create the Flask app""" app = Flask(__name__) # If DATABASE_URL or SECRET_KEY not set throw error if not os.environ.get("DATABASE_URL"): raise ValueError("DATABASE_URL environment variable is not set") if not os.environ.get("SECRET_KEY"): - raise ValueError(("Please generate a secret_key with " - "`poetry run invoke generate-secret-key`")) + raise ValueError( + ( + "Please generate a secret_key with " + "`poetry run invoke generate-secret-key`" + ) + ) app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URL") app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { "pool_pre_ping": True, @@ -40,6 +45,7 @@ def create_app(): db.init_app(app) lm.init_app(app) + csrf.init_app(app) app.cli.add_command(create_schema) app.cli.add_command(populate_db) @@ -61,7 +67,7 @@ def ping(): @click.command("create_schema") @with_appcontext def create_schema(): - """ Create the database schema """ + """Create the database schema""" logging.info("Creating schema") with open("schema.sql", "r", encoding="utf-8") as fp: sql = fp.read() @@ -75,34 +81,56 @@ def create_schema(): @click.command("populate_db") @with_appcontext def populate_db(): - """ Populate the database with sample data """ + """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 - ('Project 1', 1), - ('Project 2', 1), - ('Project 3', 1); + 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, 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 + ('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 + (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/__init__.py b/protaskinate/entities/__init__.py index fad0559..9f9ee53 100644 --- a/protaskinate/entities/__init__.py +++ b/protaskinate/entities/__init__.py @@ -2,3 +2,4 @@ from .task import Task from .project import Project from .comment import Comment +from .activity_log import ActivityLog diff --git a/protaskinate/entities/activity_log.py b/protaskinate/entities/activity_log.py new file mode 100644 index 0000000..b426dc4 --- /dev/null +++ b/protaskinate/entities/activity_log.py @@ -0,0 +1,42 @@ +"""protaskinate/entities/activity_log.py""" + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import TypedDict + +from protaskinate.utils.validation import validate_enum, validate_type + + +class ActivityLogAction(Enum): + """Enumeration representing the action of an activity log""" + + CREATE_TASK = "create_task" + UPDATE_TASK = "update_task" + DELETE_TASK = "delete_task" + UPDATE_PROJECT = "update_project" + +class NewActivityLog(TypedDict): + """Type representing the data required to create a new activity log""" + + user_id: int + project_id: int + created_at: datetime + action: ActivityLogAction + +@dataclass +class ActivityLog: + """Class representing an activity log""" + + id: int + user_id: int + project_id: int + created_at: datetime + action: ActivityLogAction + + def __post_init__(self): + validate_type(self.id, int, "id") + validate_type(self.user_id, int, "user_id") + validate_type(self.project_id, int, "project_id") + validate_type(self.created_at, datetime, "created_at") + self.action = validate_enum(self.action, ActivityLogAction, "action") diff --git a/protaskinate/entities/comment.py b/protaskinate/entities/comment.py index a7eaecb..1323c7f 100644 --- a/protaskinate/entities/comment.py +++ b/protaskinate/entities/comment.py @@ -7,6 +7,7 @@ @dataclass class Comment: """Class representing a comment""" + id: int task_id: int creator_id: int diff --git a/protaskinate/entities/project.py b/protaskinate/entities/project.py index 1c9ec1f..b5f1fb0 100644 --- a/protaskinate/entities/project.py +++ b/protaskinate/entities/project.py @@ -1,21 +1,61 @@ """protaskinate/entities/project.py""" from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Optional + +from protaskinate.entities.user import User +from protaskinate.utils.validation import validate_enum, validate_type + + +class ProjectRole(Enum): + """Enumeration representing the role of a user in a project""" + + READER = "reader" + WRITER = "writer" + ADMIN = "admin" @dataclass class Project: """Class representing a project""" + id: int name: str creator_id: int + created_at: datetime + updated_at: datetime + description: Optional[str] = None def __post_init__(self): - if not isinstance(self.id, int): - raise ValueError(f"Invalid id: {self.id}") + validate_type(self.id, int, "id") + 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: + """Class representing a project with the current user's role""" - if not isinstance(self.name, str): - raise ValueError(f"Invalid name: {self.name}") + project: Project + role: ProjectRole - if not isinstance(self.creator_id, int): - raise ValueError(f"Invalid creator_id: {self.creator_id}") + def __post_init__(self): + validate_type(self.project, Project, "project") + self.role = validate_enum(self.role, ProjectRole, "role") + + +@dataclass +class ProjectUser: + """Class representing a user in a project""" + + user: User + role: ProjectRole + + def __post_init__(self): + validate_type(self.user, User, "user") + self.role = validate_enum(self.role, ProjectRole, "role") diff --git a/protaskinate/entities/task.py b/protaskinate/entities/task.py index 25a5aca..a84f048 100644 --- a/protaskinate/entities/task.py +++ b/protaskinate/entities/task.py @@ -3,20 +3,22 @@ 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 class TaskStatus(Enum): """Enumeration representing the status of a task""" + OPEN = "open" IN_PROGRESS = "in_progress" DONE = "done" + class TaskPriority(Enum): """Enumeration representing the priority of a task""" + LOW = "low" MEDIUM = "medium" HIGH = "high" @@ -44,9 +46,11 @@ def __gt__(self, other): def __ge__(self, other): return self.as_int() >= other.as_int() + @dataclass class Task: """Class representing a task""" + id: int project_id: int creator_id: int @@ -54,10 +58,10 @@ 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 - comments: Optional[List[Comment]] = None def __post_init__(self): validate_type(self.id, int, "id") @@ -67,10 +71,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) - 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/entities/user.py b/protaskinate/entities/user.py index 550defa..9127c07 100644 --- a/protaskinate/entities/user.py +++ b/protaskinate/entities/user.py @@ -1,6 +1,5 @@ """protaskinate/entities/user.py""" - from dataclasses import dataclass from flask_login import UserMixin @@ -9,6 +8,7 @@ @dataclass class User(UserMixin): """Class representing a user""" + id: int username: str diff --git a/protaskinate/repositories/__init__.py b/protaskinate/repositories/__init__.py index 735a99d..501f536 100644 --- a/protaskinate/repositories/__init__.py +++ b/protaskinate/repositories/__init__.py @@ -2,3 +2,4 @@ from .task_repository import task_repository from .project_repository import project_repository from .comment_repository import comment_repository +from .activity_log_repository import activity_log_repository diff --git a/protaskinate/repositories/activity_log_repository.py b/protaskinate/repositories/activity_log_repository.py new file mode 100644 index 0000000..8e64da9 --- /dev/null +++ b/protaskinate/repositories/activity_log_repository.py @@ -0,0 +1,30 @@ +"""protaskinate/repositories/activity_log_repository.py""" + +from protaskinate.entities.activity_log import ActivityLog +from protaskinate.repositories.repository import Repository + + +AllFields = ["id", "user_id", "project_id", "created_at", "action"] +RequiredFields = ["user_id", "project_id", "created_at", "action"] + + +def create_activity_log_from_row(row) -> ActivityLog: + """Helper function to create an ActivityLog entity from a database row""" + return ActivityLog( + id=row[0], user_id=row[1], project_id=row[2], created_at=row[3], action=row[4] + ) + + +class ActivityLogRepository(Repository[ActivityLog]): + """ActivityLog repository for managing activity logs""" + + def __init__(self): + super().__init__( + table_name="activity_logs", + fields=AllFields, + required_fields=RequiredFields, + entity_creator=create_activity_log_from_row, + ) + + +activity_log_repository = ActivityLogRepository() diff --git a/protaskinate/repositories/comment_repository.py b/protaskinate/repositories/comment_repository.py index cf365cd..8e4b0c2 100644 --- a/protaskinate/repositories/comment_repository.py +++ b/protaskinate/repositories/comment_repository.py @@ -1,25 +1,29 @@ """protaskinate/repositories/comment_repository.py""" + from protaskinate.entities import Comment from protaskinate.repositories.repository import Repository AllFields = ["id", "task_id", "creator_id", "created_at", "content"] RequiredFields = ["task_id", "creator_id", "created_at", "content"] + def create_comment_from_row(row) -> Comment: """Helper function to create a Comment entity from a database row""" - return Comment(id=row[0], - task_id=row[1], - creator_id=row[2], - created_at=row[3], - content=row[4]) + return Comment( + id=row[0], task_id=row[1], creator_id=row[2], created_at=row[3], content=row[4] + ) + class CommentRepository(Repository[Comment]): """Comment repository for managing tasks""" def __init__(self): - super().__init__(table_name="comments", - fields=AllFields, - required_fields=RequiredFields, - entity_creator=create_comment_from_row) + super().__init__( + table_name="comments", + fields=AllFields, + required_fields=RequiredFields, + entity_creator=create_comment_from_row, + ) + comment_repository = CommentRepository() diff --git a/protaskinate/repositories/project_repository.py b/protaskinate/repositories/project_repository.py index 493c76e..35de725 100644 --- a/protaskinate/repositories/project_repository.py +++ b/protaskinate/repositories/project_repository.py @@ -1,21 +1,149 @@ """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, + ProjectUser, + ProjectWithRole, +) from protaskinate.repositories.repository import Repository +from protaskinate.repositories.user_repository import AllFields as AllUserFields +from protaskinate.repositories.user_repository import create_user_from_row +from protaskinate.utils.database import db + +AllFields = ["id", "name", "creator_id", "created_at", "updated_at", "description"] +RequiredFields = ["name", "creator_id", "created_at", "updated_at"] -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]) + 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( + project=create_project_from_row(row), role=ProjectRole(row[len(AllFields)]) + ) + + +def create_project_user_from_row(row: Row[Any]) -> ProjectUser: + """Helper function to create a ProjectUser entity from a database row""" + return ProjectUser( + user=create_user_from_row(row), role=ProjectRole(row[len(AllUserFields)]) + ) + class ProjectRepository(Repository[Project]): """Task repository for managing projects""" def __init__(self): - super().__init__(table_name="projects", - fields=AllFields, - required_fields=RequiredFields, - entity_creator=create_project_from_row) + super().__init__( + table_name="projects", + fields=AllFields, + 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]) + + def get_all_users_by_project(self, project_id: int) -> List[ProjectUser]: + """Get all users and roles in a project""" + user_fields = ", ".join(f"u.{field}" for field in AllUserFields) + sql = f"""SELECT {user_fields}, up.role + FROM users u + JOIN user_projects up ON u.id = up.user_id + WHERE up.project_id = :project_id""" + + result = db.session.execute(text(sql), {"project_id": project_id}) + rows = result.fetchall() + + return [create_project_user_from_row(row) for row in rows] + + def update_user_role( + self, project_id: int, user_id: int, role: ProjectRole + ) -> None: + """Update the role of a user in a project""" + sql = """UPDATE user_projects + SET role = :role + WHERE project_id = :project_id AND user_id = :user_id""" + + db.session.execute( + text(sql), + {"role": role.value, "project_id": project_id, "user_id": user_id}, + ) + db.session.commit() + + def create_project_user( + self, project_id: int, user_id: int, role: ProjectRole + ) -> Optional[ProjectUser]: + """Add a user to a project""" + user_fields = ", ".join(f"u.{field}" for field in AllUserFields) + + sql = f"""WITH new AS ( + INSERT INTO user_projects (project_id, user_id, role) + VALUES (:project_id, :user_id, :role) + RETURNING user_id, project_id, role + ) + SELECT {user_fields}, new.role + FROM new new + JOIN users u ON u.id = new.user_id""" + + result = db.session.execute( + text(sql), + {"project_id": project_id, "user_id": user_id, "role": role.value}, + ) + row = result.fetchone() + db.session.commit() + if row is None: + return None + + return create_project_user_from_row(row) + project_repository = ProjectRepository() diff --git a/protaskinate/repositories/repository.py b/protaskinate/repositories/repository.py index 6f777b1..4cb8f6a 100644 --- a/protaskinate/repositories/repository.py +++ b/protaskinate/repositories/repository.py @@ -1,4 +1,5 @@ """protaskinate/repositories/repository.py""" + from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, Union from sqlalchemy import Row, text @@ -7,19 +8,28 @@ T = TypeVar("T") + class Repository(Generic[T]): """Generic base repository class for common CRUD operations""" - def __init__(self, table_name: str, fields: List[str], - required_fields: List[str], entity_creator: Callable[[Row[Any]], T]): + def __init__( + self, + table_name: str, + fields: List[str], + required_fields: List[str], + entity_creator: Callable[[Row[Any]], T], + ): self._table_name = table_name self._fields = fields self._required_fields = required_fields self._entity_creator = entity_creator - def get_all(self, by_fields: Optional[Dict[str, Union[int, str]]] = None, - order_by_fields: Optional[List[str]] = None, - reverse: Optional[List[bool]] = None) -> List[T]: + def get_all( + self, + by_fields: Optional[Dict[str, Union[int, str]]] = None, + order_by_fields: Optional[List[str]] = None, + reverse: Optional[List[bool]] = None, + ) -> List[T]: """Get all entities from the repository by some fields""" if by_fields is not None: if any(key not in self._fields for key in by_fields): @@ -39,9 +49,13 @@ def get_all(self, by_fields: Optional[Dict[str, Union[int, str]]] = None, for field, rev in zip(order_by_fields, reverse) ) if by_fields is not None: - where_clause = "WHERE " + " AND ".join(f"{key} = :{key}" for key in by_fields) + where_clause = "WHERE " + " AND ".join( + f"{key} = :{key}" for key in by_fields + ) - sql = f"SELECT {all_fields} FROM {self._table_name} {where_clause} {order_clause}" + sql = ( + f"SELECT {all_fields} FROM {self._table_name} {where_clause} {order_clause}" + ) result = db.session.execute(text(sql), by_fields) rows = result.fetchall() @@ -62,6 +76,20 @@ def get(self, by_fields: Dict[str, Union[int, str]]) -> Optional[T]: return self._entity_creator(row) if row else None + def count(self, by_fields: Dict[str, Union[int, str]]) -> int: + """Get the count of entities from the repository by some fields""" + if any(key not in self._fields for key in by_fields): + raise ValueError("Invalid by_fields") + + where_clause = "WHERE " + " AND ".join(f"{key} = :{key}" for key in by_fields) + + sql = f"SELECT count(*) FROM {self._table_name} {where_clause}" + + result = db.session.execute(text(sql), by_fields) + row = result.fetchone() + + return int(row[0]) if row else 0 + def create(self, field_values: Dict[str, Union[int, str]]) -> Optional[T]: """Create a new entity in the repository""" if any(key not in self._fields for key in field_values): @@ -70,10 +98,11 @@ def create(self, field_values: Dict[str, Union[int, str]]) -> Optional[T]: raise ValueError("Missing required fields") fields = ", ".join(field_values.keys()) - values = ", ".join(f":{key}" for key in field_values) + placeholders = ", ".join(f":{key}" for key in field_values) all_fields = ", ".join(self._fields) - sql = f"INSERT INTO {self._table_name} ({fields}) VALUES ({values}) RETURNING {all_fields}" + sql = f"""INSERT INTO {self._table_name} ({fields}) + VALUES ({placeholders}) RETURNING {all_fields}""" result = db.session.execute(text(sql), field_values) row = result.fetchone() @@ -81,8 +110,9 @@ def create(self, field_values: Dict[str, Union[int, str]]) -> Optional[T]: return self._entity_creator(row) if row else None - def update(self, by_fields: Dict[str, Union[int, str]], - updates: Dict[str, Union[int, str]]) -> Optional[T]: + def update( + self, by_fields: Dict[str, Union[int, str]], updates: Dict[str, Union[int, str]] + ) -> Optional[T]: """Update an entity in the repository""" if not by_fields or any(key not in self._fields for key in by_fields): raise ValueError("Invalid by_fields") diff --git a/protaskinate/repositories/task_repository.py b/protaskinate/repositories/task_repository.py index 6268535..bd2b5d9 100644 --- a/protaskinate/repositories/task_repository.py +++ b/protaskinate/repositories/task_repository.py @@ -1,69 +1,80 @@ """protaskinate/repositories/task_repository.py""" -from typing import Dict, Optional, Union +import logging +from typing import Dict from sqlalchemy import text - from protaskinate.entities import Task -from protaskinate.repositories.comment_repository import \ - create_comment_from_row, AllFields as CommentAllFields +from protaskinate.entities.task import TaskStatus from protaskinate.repositories.repository import Repository 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"] +AllFields = [ + "id", + "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""" - return Task(id=row[0], - project_id=row[1], - creator_id=row[2], - title=row[3], - status=row[4], - priority=row[5], - created_at=row[6], - assignee_id=row[7], - deadline=row[8], - description=row[9]) + return Task( + id=row[0], + project_id=row[1], + creator_id=row[2], + title=row[3], + status=row[4], + priority=row[5], + created_at=row[6], + updated_at=row[7], + assignee_id=row[8], + deadline=row[9], + description=row[10], + ) + class TaskRepository(Repository[Task]): """Task repository for managing tasks""" def __init__(self): - super().__init__(table_name="tasks", - fields=AllFields, - required_fields=RequiredFields, - entity_creator=create_task_from_row) + super().__init__( + table_name="tasks", + fields=AllFields, + 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") + def count_by_assignee_grouped_by_status( + self, assignee_id: int + ) -> Dict[TaskStatus, int]: + """Get all tasks assigned to a user grouped by status""" + sql = """SELECT ts::text, COALESCE(COUNT(task.id), 0) + FROM unnest(enum_range(NULL::task_status)) AS ts + LEFT JOIN tasks task ON task.status = ts AND task.assignee_id = :assignee_id + GROUP BY ts""" - 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) + result = db.session.execute(text(sql), {"assignee_id": assignee_id}) 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) + logging.info(rows) - return task + return {TaskStatus(row[0]): int(row[1]) for row in rows} task_repository = TaskRepository() diff --git a/protaskinate/repositories/user_repository.py b/protaskinate/repositories/user_repository.py index 94c2f7b..9c4ceb8 100644 --- a/protaskinate/repositories/user_repository.py +++ b/protaskinate/repositories/user_repository.py @@ -1,6 +1,8 @@ """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 @@ -8,29 +10,33 @@ AllFields = ["id", "username", "password"] RequiredFields = ["username", "password"] + def create_user_from_row(row) -> User: """Helper function to create a User entity from a database row""" return User(id=row[0], username=row[1]) + class UserRepository(Repository[User]): """User repository for managing users""" def __init__(self): - super().__init__(table_name="users", - fields=AllFields, - required_fields=RequiredFields, - entity_creator=create_user_from_row) + super().__init__( + table_name="users", + fields=AllFields, + required_fields=RequiredFields, + entity_creator=create_user_from_row, + ) def verify_password(self, username: str, password: str) -> bool: """Verify a password against a password hash""" sql = f"""SELECT password FROM {self._table_name} WHERE username = :username""" - result = db.session.execute(text(sql), - {"username": username}) + result = db.session.execute(text(sql), {"username": username}) row = result.fetchone() if row is None: return False return check_password_hash(row[0], password) + user_repository = UserRepository() diff --git a/protaskinate/routes/dashboard.py b/protaskinate/routes/dashboard.py index 2f60d39..78f5495 100644 --- a/protaskinate/routes/dashboard.py +++ b/protaskinate/routes/dashboard.py @@ -1,18 +1,27 @@ """protaskinate/routes/dashboard.py""" from flask import Blueprint, redirect, render_template, url_for -from flask_login import login_required +from flask_login import current_user, login_required + +from protaskinate.services import task_service blueprint = Blueprint("dashboard", __name__) + @blueprint.route("/", methods=["GET"]) @login_required def index_route(): """Redirect to the dashboard""" return redirect(url_for("dashboard.dashboard_route")) + @blueprint.route("/dashboard", methods=["GET"]) @login_required def dashboard_route(): """Render the dashboard page""" - return render_template("dashboard.html") + task_counts_by_status = task_service.count_by_assignee_grouped_by_status( + current_user.id + ) + return render_template( + "dashboard.html", task_counts_by_status=task_counts_by_status + ) diff --git a/protaskinate/routes/forms/login_forms.py b/protaskinate/routes/forms/login_forms.py new file mode 100644 index 0000000..a81f88f --- /dev/null +++ b/protaskinate/routes/forms/login_forms.py @@ -0,0 +1,13 @@ +""" protaskinate/routes/forms/project_forms.py """ + +from flask_wtf import FlaskForm +from wtforms import PasswordField, StringField, SubmitField +from wtforms.validators import DataRequired + + +class LoginForm(FlaskForm): + """Form for login""" + + username = StringField("Username", validators=[DataRequired()]) + password = PasswordField("Password", validators=[DataRequired()]) + submit = SubmitField("Login") diff --git a/protaskinate/routes/forms/project_forms.py b/protaskinate/routes/forms/project_forms.py new file mode 100644 index 0000000..e46b534 --- /dev/null +++ b/protaskinate/routes/forms/project_forms.py @@ -0,0 +1,71 @@ +""" protaskinate/routes/forms/project_forms.py """ + +from flask_wtf import FlaskForm +from wtforms import DateField, SelectField, StringField, SubmitField, TextAreaField +from wtforms.validators import DataRequired, Length, Optional + +from protaskinate.entities.project import ProjectRole +from protaskinate.entities.task import TaskPriority, TaskStatus +from protaskinate.services import user_service + + +class CreateTaskForm(FlaskForm): + """Form for creating a task""" + + 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()) + for status in TaskStatus + ], + ) + priority = SelectField( + "Priority", + choices=[ + (priority.value, priority.name.lower().replace("_", " ").title()) + for priority in TaskPriority + ], + ) + assignee_id = SelectField("Assignee", coerce=int) + deadline = DateField("Deadline", format="%Y-%m-%d", validators=[Optional()]) + submit = SubmitField("Create Task") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.assignee_id.choices = [(0, "Not Assigned")] + [ + (user.id, user.username) for user in user_service.get_all() + ] # type: ignore + + +class CreateCommentForm(FlaskForm): + """Form for creating a comment""" + + content = TextAreaField("Content", validators=[DataRequired(), Length(max=500)]) + submit = SubmitField("Send") + + +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") + + +class CreateProjectUserForm(FlaskForm): + """Form for adding a user to a project""" + + user_id = SelectField("User", coerce=int) + role = SelectField( + "Role", + choices=[(role.value, role.name.lower().title()) for role in ProjectRole], + ) + submit = SubmitField("Add User") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user_id.choices = [ + (user.id, user.username) for user in user_service.get_all() + ] diff --git a/protaskinate/routes/forms/register_forms.py b/protaskinate/routes/forms/register_forms.py new file mode 100644 index 0000000..de0aba5 --- /dev/null +++ b/protaskinate/routes/forms/register_forms.py @@ -0,0 +1,20 @@ +"""protaskinate/routes/forms/register_forms.py""" + +from flask_wtf import FlaskForm +from wtforms import PasswordField, StringField, SubmitField +from wtforms.validators import DataRequired, EqualTo, Length + + +class RegisterForm(FlaskForm): + """Form for registering a user""" + + username = StringField( + "Username", validators=[DataRequired(), Length(min=3, max=20)] + ) + password = PasswordField( + "Password", validators=[DataRequired(), Length(min=6, max=20)] + ) + confirm_password = PasswordField( + "Confirm Password", validators=[DataRequired(), EqualTo("password")] + ) + submit = SubmitField("Register") diff --git a/protaskinate/routes/handlers/login_handlers.py b/protaskinate/routes/handlers/login_handlers.py new file mode 100644 index 0000000..082f124 --- /dev/null +++ b/protaskinate/routes/handlers/login_handlers.py @@ -0,0 +1,26 @@ +"""protaskinate/routes/handlers/login_handlers.py""" + +from flask import flash +from flask_login import login_user + +from protaskinate.routes.forms.login_forms import LoginForm +from protaskinate.services import user_service + + +def handle_login(form: LoginForm) -> bool: + """Handle the login of a user""" + username = form.username.data + password = form.password.data + + if not username or not password: + flash("Invalid username or password", "error") + return False + + user = user_service.login(username, password) + if not user: + flash("Invalid username or password", "error") + return False + + login_user(user) + flash("You have been logged in", "success") + return True diff --git a/protaskinate/routes/handlers/project_handlers.py b/protaskinate/routes/handlers/project_handlers.py new file mode 100644 index 0000000..91fec5e --- /dev/null +++ b/protaskinate/routes/handlers/project_handlers.py @@ -0,0 +1,199 @@ +"""protaskinate/routes/handlers/project_handlers.py""" + +from datetime import datetime +from typing import Dict +import logging + +from flask import flash +from flask_login import current_user + +from protaskinate.entities.activity_log import ActivityLogAction +from protaskinate.entities.project import ProjectRole +from protaskinate.routes.forms.project_forms import ( + CreateCommentForm, + CreateProjectForm, + CreateProjectUserForm, + CreateTaskForm, +) +from protaskinate.services import ( + activity_log_service, + comment_service, + project_service, + task_service, +) + + +def handle_create_project(form: CreateProjectForm): + """Handle the creation of a project""" + data = { + "name": form.name.data, + "description": form.description.data if form.description.data else None, + } + + project_service.create( + name=data["name"], + creator_id=current_user.id, + created_at=datetime.now().isoformat(), + updated_at=datetime.now().isoformat(), + description=data["description"], + ) + + form.name.data = "" + form.description.data = "" + flash("Project created successfully", "success") + + +def handle_create_task(project_id: int, form: CreateTaskForm): + """Handle the creation of a task""" + data = { + "title": form.title.data, + "status": form.status.data, + "priority": form.priority.data, + "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, + } + + if not project_service.check_user_write_access(current_user.id, project_id): + flash("You do not have write-access to this project", "error") + return + + new_task = task_service.create( + title=data["title"], + status=data["status"], + priority=data["priority"], + creator_id=current_user.id, + created_at=datetime.now().isoformat(), + updated_at=datetime.now().isoformat(), + assignee_id=data["assignee_id"], + deadline=data["deadline"], + project_id=project_id, + description=data["description"], + ) + if not new_task: + flash("Failed to create task", "error") + return + + new_log = activity_log_service.create_log( + user_id=current_user.id, + project_id=project_id, + action=ActivityLogAction.CREATE_TASK, + ) + if not new_log: + logging.error("Failed to create activity log for task creation") + + form.title.data = "" + form.assignee_id.data = 0 + form.deadline.data = None + form.description.data = "" + flash("Task created successfully", "success") + + +def handle_create_project_user(project_id: int, form: CreateProjectUserForm): + """Handle the creation of a project user""" + data = {"user_id": form.user_id.data} + + if not project_service.check_user_update_access(current_user.id, project_id): + flash("You do not have update-access to this project", "error") + return + + try: + data["role"] = ProjectRole(form.role.data) + except ValueError: + flash("Invalid role", "error") + return + + if project_service.get_user_role(data["user_id"], project_id) is not None: + flash("User already in project", "error") + return + + new_project_user = project_service.add_user(project_id, data["user_id"], data["role"]) + if not new_project_user: + flash("Failed to add user to project", "error") + return + + new_log = activity_log_service.create_log( + user_id=current_user.id, + project_id=project_id, + action=ActivityLogAction.UPDATE_PROJECT, + ) + if not new_log: + logging.error("Failed to create activity log for project user addition") + + flash("User added to project", "success") + + +def handle_update_user_role(project_id: int, user_id: int, form: Dict): + """Handle the update of a user's role in a project""" + try: + role = ProjectRole(form["role"]) + except ValueError: + flash("Invalid role", "error") + return + + project_service.update_user_role(project_id, user_id, role) + + new_log = activity_log_service.create_log( + user_id=current_user.id, + project_id=project_id, + action=ActivityLogAction.UPDATE_PROJECT, + ) + if not new_log: + logging.error("Failed to create activity log for user role update") + + +def handle_create_comment(task_id: int, form: CreateCommentForm): + """Handle the creation of a comment""" + content = form.content.data + + new_comment = comment_service.create( + task_id=task_id, + creator_id=current_user.id, + created_at=datetime.now().isoformat(), + content=content, + ) + + if not new_comment: + flash("Failed to create comment", "error") + return + + form.content.data = "" + + +def handle_update_task(task_id: int, project_id: int, form: Dict): + """Handle the update of a task""" + update_data = {} + if "status" in form: + update_data["status"] = form["status"] + if "priority" in form: + update_data["priority"] = form["priority"] + if "assignee_id" in form: + update_data["assignee_id"] = int(form["assignee_id"]) + if update_data["assignee_id"] == 0: + update_data["assignee_id"] = None + if "deadline" in form: + update_data["deadline"] = form["deadline"] + + updated_task = task_service.update(task_id, project_id, **update_data) + if not updated_task: + flash("Failed to update task", "error") + return + + new_log = activity_log_service.create_log( + user_id=current_user.id, + project_id=project_id, + action=ActivityLogAction.UPDATE_TASK, + ) + if not new_log: + logging.error("Failed to create activity log for task update") + +def handle_delete_task(project_id: int, task_id: int): + """Handle the deletion of a task""" + task_service.delete(task_id, project_id) + new_log = activity_log_service.create_log( + user_id=current_user.id, + project_id=project_id, + action=ActivityLogAction.DELETE_TASK, + ) + if not new_log: + logging.error("Failed to create activity log for task deletion") diff --git a/protaskinate/routes/handlers/register_handlers.py b/protaskinate/routes/handlers/register_handlers.py new file mode 100644 index 0000000..0bbe734 --- /dev/null +++ b/protaskinate/routes/handlers/register_handlers.py @@ -0,0 +1,29 @@ +"""protaskinate/routes/handlers/register_handlers.py""" + +from flask import flash +from flask_login import login_user +from protaskinate.routes.forms.register_forms import RegisterForm +from protaskinate.services import user_service + + +def handle_register(form: RegisterForm) -> bool: + """Handle the registration of a user""" + username = form.username.data + password = form.password.data + + if not username or not password: + flash("Missing username or password", "error") + return False + + if user_service.get_by_username(username): + flash("Username already exists", "error") + return False + + registered_user = user_service.register(username, password) + if not registered_user: + flash("Failed to register user", "error") + return False + + login_user(registered_user) + flash("You have been registered", "success") + return True diff --git a/protaskinate/routes/login.py b/protaskinate/routes/login.py index 2f21676..e622d46 100644 --- a/protaskinate/routes/login.py +++ b/protaskinate/routes/login.py @@ -1,22 +1,13 @@ """protaskinate/routes/login.py""" -from flask import Blueprint, flash, redirect, render_template, request, url_for -from flask_login import login_user -from flask_wtf import FlaskForm -from wtforms import PasswordField, StringField, SubmitField -from wtforms.validators import DataRequired +from flask import Blueprint, redirect, render_template, request, url_for -from protaskinate.services import user_service -from protaskinate.utils.login_manager import lm, login_redirect +from protaskinate.routes.forms.login_forms import LoginForm +from protaskinate.routes.handlers.login_handlers import handle_login +from protaskinate.utils.login_manager import login_redirect blueprint = Blueprint("login", __name__) -class LoginForm(FlaskForm): - """Form for login""" - username = StringField("Username", validators=[DataRequired()]) - - password = PasswordField("Password", validators=[DataRequired()]) - submit = SubmitField("Login") @blueprint.route("/login", methods=["GET", "POST"]) @login_redirect @@ -25,19 +16,7 @@ def login_route(): form = LoginForm(request.form) if request.method == "POST" and form.validate_on_submit(): - username = form.username.data - password = form.password.data - - user = user_service.login(username, password) - if user: - login_user(user) - flash("You have been logged in", "success") + if handle_login(form): return redirect(url_for("dashboard.dashboard_route")) - flash("Invalid username or password", "error") return render_template("login.html", form=form) - -@lm.user_loader -def load_user(user_id): - """Load the user""" - return user_service.get_by_id(user_id) diff --git a/protaskinate/routes/logout.py b/protaskinate/routes/logout.py index d7ab328..24928b3 100644 --- a/protaskinate/routes/logout.py +++ b/protaskinate/routes/logout.py @@ -5,6 +5,7 @@ blueprint = Blueprint("logout", __name__) + @blueprint.route("/logout", methods=["GET"]) def logout_route(): """Render the logout page""" diff --git a/protaskinate/routes/project.py b/protaskinate/routes/project.py index cf73115..d027336 100644 --- a/protaskinate/routes/project.py +++ b/protaskinate/routes/project.py @@ -1,111 +1,148 @@ """protaskinate/routes/project.py""" -from datetime import datetime - -from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask import Blueprint, redirect, render_template, request, url_for from flask_login import current_user, login_required -from flask_wtf import FlaskForm -from wtforms import (DateField, SelectField, StringField, SubmitField, - TextAreaField) -from wtforms.validators import DataRequired, Optional -from protaskinate.entities.task import TaskPriority, TaskStatus -from protaskinate.services import (comment_service, project_service, - task_service, user_service) +from protaskinate.routes.forms.project_forms import ( + CreateCommentForm, + CreateProjectForm, + CreateProjectUserForm, + CreateTaskForm, +) +from protaskinate.routes.handlers.project_handlers import ( + handle_create_comment, + handle_create_project, + handle_create_project_user, + handle_create_task, + handle_delete_task, + handle_update_task, + handle_update_user_role, +) +from protaskinate.services import ( + activity_log_service, + 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()]) - description = TextAreaField("Description", validators=[Optional()]) - status = SelectField("Status", - choices=[ - (status.value, status.name.lower().replace("_"," ").title()) - for status in TaskStatus]) - priority = SelectField("Priority", - choices=[ - (priority.value, priority.name.lower().replace("_"," ").title()) - for priority in TaskPriority]) - assignee_id = SelectField("Assignee", coerce=int) - deadline = DateField("Deadline", format="%Y-%m-%d", validators=[Optional()]) - submit = SubmitField("Create Task") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.assignee_id.choices = [(0, "Not Assigned")] + [ - (user.id, user.username) for user in user_service.get_all()] # type: ignore - -class CreateCommentForm(FlaskForm): - """Form for creating a comment""" - content = TextAreaField("Content", validators=[DataRequired()]) - submit = SubmitField("Send") - -@blueprint.route("/projects", methods=["GET"]) +@blueprint.route("/projects", methods=["GET", "POST"]) @login_required def project_list_route(): """Render the projects page""" - projects = project_service.get_all() - return render_template("project_list.html", projects=projects) + form = CreateProjectForm(request.form) + if request.method == "POST" and form.validate_on_submit(): + handle_create_project(form) + + projects_with_roles = project_service.get_all_by_user_with_role(current_user.id) + return render_template( + "project_list.html", form=form, projects_with_roles=projects_with_roles + ) + @blueprint.route("/projects/