diff --git a/todo_app/.gitignore b/todo_app/.gitignore new file mode 100644 index 0000000..869a1db --- /dev/null +++ b/todo_app/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Databases +*.db +*.sqlite3 +instance/ + +# Environment variables +.env + +# Flask session files +.webassets-cache + +# Virtual environment +venv/ +env/ +.venv/ + +# IDE / Editor specific +.vscode/ +.idea/ +*.sublime-workspace +*.sublime-project + +# OS specific +.DS_Store +Thumbs.db diff --git a/todo_app/app/__init__.py b/todo_app/app/__init__.py new file mode 100644 index 0000000..465039b --- /dev/null +++ b/todo_app/app/__init__.py @@ -0,0 +1,45 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_bcrypt import Bcrypt +from .models import User # Import User model + +# Initialize extensions +db = SQLAlchemy() +bcrypt = Bcrypt() +login_manager = LoginManager() +# Assuming 'auth' will be a blueprint for authentication routes and 'login' is the login route. +login_manager.login_view = 'auth.login' +login_manager.login_message_category = 'info' # For flash messages +migrate = Migrate() + +def create_app(config_class='config.Config'): + """ + Factory function to create the Flask application. + """ + app = Flask(__name__) + app.config.from_object(config_class) + + # Initialize extensions with the app + db.init_app(app) + bcrypt.init_app(app) + login_manager.init_app(app) + migrate.init_app(app, db) + + # User loader function for Flask-Login + @login_manager.user_loader + def load_user(user_id): + return User.query.get(int(user_id)) + + # Placeholder for blueprint registration + from .main import main_bp # Import main blueprint + app.register_blueprint(main_bp) + + from .auth import auth_bp # Import auth blueprint + app.register_blueprint(auth_bp, url_prefix='/auth') + + # from .task_routes import task_bp # Placeholder for task routes + # app.register_blueprint(task_bp, url_prefix='/task') + + return app diff --git a/todo_app/app/auth/__init__.py b/todo_app/app/auth/__init__.py new file mode 100644 index 0000000..a061171 --- /dev/null +++ b/todo_app/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +auth_bp = Blueprint('auth', __name__, template_folder='templates') + +from . import routes diff --git a/todo_app/app/auth/routes.py b/todo_app/app/auth/routes.py new file mode 100644 index 0000000..5385074 --- /dev/null +++ b/todo_app/app/auth/routes.py @@ -0,0 +1,43 @@ +from flask import render_template, redirect, url_for, flash, request +from flask_login import login_user, logout_user, login_required, current_user +from .. import db, bcrypt # Corrected import path +from ..models import User # Corrected import path +from ..forms import RegistrationForm, LoginForm # Corrected import path +from . import auth_bp + +@auth_bp.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) # Assuming main.index will exist + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data, role=form.role.data) + user.set_password(form.password.data) # Use the method from User model + db.session.add(user) + db.session.commit() + flash('Your account has been created! You are now able to log in.', 'success') + return redirect(url_for('auth.login')) + return render_template('auth/register.html', title='Register', form=form) + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) # Assuming main.index will exist + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + if user and user.check_password(form.password.data): # Use the method from User model + login_user(user) # Removed remember=form.remember.data, as 'remember' is not in form + next_page = request.args.get('next') + flash('Login successful.', 'success') + return redirect(next_page) if next_page else redirect(url_for('main.index')) + else: + flash('Login Unsuccessful. Please check username and password.', 'danger') + return render_template('auth/login.html', title='Login', form=form) + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out.', 'info') + return redirect(url_for('auth.login')) diff --git a/todo_app/app/auth/templates/auth/login.html b/todo_app/app/auth/templates/auth/login.html new file mode 100644 index 0000000..e69de29 diff --git a/todo_app/app/auth/templates/auth/register.html b/todo_app/app/auth/templates/auth/register.html new file mode 100644 index 0000000..e69de29 diff --git a/todo_app/app/decorators.py b/todo_app/app/decorators.py new file mode 100644 index 0000000..0148923 --- /dev/null +++ b/todo_app/app/decorators.py @@ -0,0 +1,19 @@ +from functools import wraps +from flask_login import current_user +from flask import abort + +def manager_required(f): + """ + Decorator to ensure a user is logged in and has the 'manager' role. + Aborts with a 403 error if conditions are not met. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + # This case should ideally be handled by @login_required, + # but it's good practice for a role decorator to check. + abort(401) # Unauthorized + if current_user.role != 'manager': + abort(403) # Forbidden + return f(*args, **kwargs) + return decorated_function diff --git a/todo_app/app/forms.py b/todo_app/app/forms.py new file mode 100644 index 0000000..7426fba --- /dev/null +++ b/todo_app/app/forms.py @@ -0,0 +1,64 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField, SelectField, TextAreaField, DateField +from wtforms.validators import DataRequired, Length, EqualTo, ValidationError, Optional +from .models import User # User is already imported, good. + +class RegistrationForm(FlaskForm): + username = StringField('Username', + validators=[DataRequired(), Length(min=2, max=80)]) # Max length matches User model + password = PasswordField('Password', + validators=[DataRequired(), Length(min=6)]) + confirm_password = PasswordField('Confirm Password', + validators=[DataRequired(), EqualTo('password')]) + role = SelectField('Role', + choices=[('employee', 'Employee'), ('manager', 'Manager')], + validators=[DataRequired()]) + submit = SubmitField('Sign Up') + + def validate_username(self, username): + user = User.query.filter_by(username=username.data).first() + if user: + raise ValidationError('That username is already taken. Please choose a different one.') + +class LoginForm(FlaskForm): + username = StringField('Username', + validators=[DataRequired(), Length(min=2, max=80)]) # Max length matches User model + password = PasswordField('Password', validators=[DataRequired()]) + submit = SubmitField('Login') + +class TaskForm(FlaskForm): + title = StringField('Title', validators=[DataRequired()]) + description = TextAreaField('Description') + due_date = DateField('Due Date', format='%Y-%m-%d', validators=[Optional()]) + status = SelectField('Status', + choices=[('pending', 'Pending'), + ('in progress', 'In Progress'), + ('completed', 'Completed')], + validators=[DataRequired()]) # Default is typically the first choice + assignee_id = SelectField('Assign To', coerce=int, validators=[Optional()]) # Added Optional for now, will adjust in __init__ + submit = SubmitField('Save Task') + + def __init__(self, *args, **kwargs): + current_user_role = kwargs.pop('current_user_role', None) + super(TaskForm, self).__init__(*args, **kwargs) + + if current_user_role == 'manager': + # Populate choices for assignee_id + employees = User.query.filter_by(role='employee').all() + self.assignee_id.choices = [(user.id, user.username) for user in employees] + # Add an option for 'Unassigned' or 'Assign to Self (Manager)' if desired + # For now, let's make it required to select an employee if manager is creating/editing + self.assignee_id.choices.insert(0, (0, 'Unassigned / Assign to Self')) # Representing unassigned or manager self-assignment + self.assignee_id.validators = [DataRequired()] # Make it required for manager + else: + # For employees, this field might be hidden or disabled in the template. + # Or, we can remove it from the form items if it's not relevant. + # For now, let's give it a default choice that makes sense if it were to be submitted. + self.assignee_id.choices = [] # No choices for employee to change assignee + # If we want to ensure it's not submitted by employees, we can clear validators or set a default. + # However, the route logic will handle setting assignee_id to current_user.id for employees. + # So, an empty choices list and Optional validator is fine. + # If current_user is available here, we could set a default choice: + # self.assignee_id.choices = [(current_user.id, current_user.username)] + # self.assignee_id.data = current_user.id + pass # Keep validators as Optional for non-managers diff --git a/todo_app/app/main/__init__.py b/todo_app/app/main/__init__.py new file mode 100644 index 0000000..290fa70 --- /dev/null +++ b/todo_app/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +main_bp = Blueprint('main', __name__, template_folder='templates') + +from . import routes diff --git a/todo_app/app/main/routes.py b/todo_app/app/main/routes.py new file mode 100644 index 0000000..e255b51 --- /dev/null +++ b/todo_app/app/main/routes.py @@ -0,0 +1,174 @@ +from flask import render_template, redirect, url_for, flash, abort, request +from flask_login import login_required, current_user +from .. import db # Assuming db is initialized in app's __init__.py +from ..models import Task # Assuming Task model is in app's models.py +from ..forms import TaskForm # Assuming TaskForm is in app's forms.py +from ..decorators import manager_required # Import manager_required +from datetime import datetime +from . import main_bp + +@main_bp.route('/') +@login_required +def index(): + # Redirect to the list of tasks for the logged-in user + return redirect(url_for('main.list_tasks')) + +# Placeholder for list_tasks, to be implemented next +@main_bp.route('/tasks') +@login_required +def list_tasks(): + # Query tasks where assignee_id == current_user.id + tasks = Task.query.filter_by(assignee_id=current_user.id).order_by(Task.due_date.asc(), Task.created_at.desc()).all() + # Assuming template 'main/list_tasks.html' exists or will be created + return render_template('main/list_tasks.html', tasks=tasks, title="My Tasks") + + +@main_bp.route('/task/new', methods=['GET', 'POST']) +@login_required +def create_task(): + form = TaskForm(current_user_role=current_user.role) # Pass current_user_role + if form.validate_on_submit(): + due_date_val = form.due_date.data + task = Task( + title=form.title.data, + description=form.description.data, + due_date=due_date_val, + status=form.status.data, + creator_id=current_user.id + # assignee_id will be set below + ) + + if current_user.role == 'manager' and form.assignee_id.data and form.assignee_id.data != 0: + task.assignee_id = form.assignee_id.data + elif current_user.role == 'manager' and form.assignee_id.data == 0: # Manager chose 'Unassigned / Assign to Self' + task.assignee_id = current_user.id # Assign to self (manager) + else: # Employee is creating the task + task.assignee_id = current_user.id # Employee assigns to self + + db.session.add(task) + db.session.commit() + flash('Your task has been created!', 'success') + return redirect(url_for('main.list_tasks')) + # Assuming template 'main/create_edit_task.html' exists or will be created + return render_template('main/create_edit_task.html', title='New Task', form=form, is_edit=False) + + +@main_bp.route('/task/') +@login_required +def view_task(task_id): + task = Task.query.get_or_404(task_id) + if task.assignee_id != current_user.id: + # For now, only assignee can view. Manager logic can be added later. + abort(403) + # Assuming template 'main/view_task.html' exists or will be created + return render_template('main/view_task.html', task=task, title=task.title) + + +@main_bp.route('/task//edit', methods=['GET', 'POST']) +@login_required +def edit_task(task_id): + task = Task.query.get_or_404(task_id) + # Permission check: Manager can edit any task. Employee can only edit their own assigned tasks. + if current_user.role != 'manager' and task.assignee_id != current_user.id: + abort(403) + + form = TaskForm(current_user_role=current_user.role) # Pass current_user_role + if form.validate_on_submit(): + task.title = form.title.data + task.description = form.description.data + task.due_date = form.due_date.data + task.status = form.status.data + task.updated_at = datetime.utcnow() + + if current_user.role == 'manager': + if form.assignee_id.data and form.assignee_id.data != 0: + task.assignee_id = form.assignee_id.data + elif form.assignee_id.data == 0: # Manager chose 'Unassigned / Assign to Self' + # If the task was assigned to someone else, and manager chooses 'Unassigned', + # it means it becomes assigned to the manager themselves. + task.assignee_id = current_user.id + # If no assignee_id is provided by a manager (e.g. if field was optional for some reason), + # do not change the assignee, or assign to self - current logic implies it must be selected. + # The form validator DataRequired for manager role ensures a selection is made. + # Employees cannot change assignee, so no 'else' needed here for task.assignee_id + + db.session.commit() + flash('Your task has been updated!', 'success') + return redirect(url_for('main.view_task', task_id=task.id)) + elif request.method == 'GET': + form.title.data = task.title + form.description.data = task.description + form.due_date.data = task.due_date + form.status.data = task.status + if current_user.role == 'manager': + # If task.assignee_id is None or not a valid choice, WTForms might have issues. + # The (0, 'Unassigned / Assign to Self') choice handles if task.assignee_id is current_user.id (the manager) + # or if it's genuinely unassigned (though our model implies assignee is often set). + # If task.assignee_id points to an employee, it will select them. + # If task.assignee_id is current_user.id (manager), it should select the 'Unassigned / Assign to Self' (0) option. + if task.assignee_id == current_user.id: + form.assignee_id.data = 0 # Select the 'Unassigned / Assign to Self' option + else: + form.assignee_id.data = task.assignee_id + + # Assuming template 'main/create_edit_task.html' exists or will be created + return render_template('main/create_edit_task.html', title='Edit Task', form=form, task=task, is_edit=True) + + +@main_bp.route('/task//delete', methods=['POST']) +@login_required +def delete_task(task_id): + task = Task.query.get_or_404(task_id) + if task.assignee_id != current_user.id: + abort(403) # User cannot delete tasks not assigned to them + db.session.delete(task) + db.session.commit() + flash('Your task has been deleted!', 'success') + return redirect(url_for('main.list_tasks')) + + +@main_bp.route('/task//complete', methods=['POST']) +@login_required +def complete_task(task_id): + task = Task.query.get_or_404(task_id) + if task.assignee_id != current_user.id: + abort(403) # User cannot complete tasks not assigned to them + + task.status = 'completed' + task.updated_at = datetime.utcnow() + db.session.commit() + flash('Task marked as completed!', 'success') + return redirect(url_for('main.list_tasks')) + + +# The old view_task_old placeholder can be removed. +# (The old view_task_old has been removed by not including it here) + + +@main_bp.route('/admin/tasks') +@login_required +@manager_required +def view_all_tasks(): + # Query all tasks. Join with User to access creator/assignee usernames if needed in template. + # Ordered by due_date, then by creation_date. + tasks = Task.query.order_by(Task.due_date.asc(), Task.created_at.desc()).all() + # Assuming template 'main/all_tasks.html' exists or will be created + return render_template('main/all_tasks.html', tasks=tasks, title="All Tasks") + +@main_bp.route('/task//approve', methods=['POST']) +@login_required +@manager_required +def approve_task(task_id): + task = Task.query.get_or_404(task_id) + + if task.status != 'completed': + flash('Task must be marked as "completed" before it can be approved.', 'warning') + return redirect(url_for('main.view_task', task_id=task.id)) # Or main.all_tasks + + task.status = 'approved' + task.approved_by_id = current_user.id + task.updated_at = datetime.utcnow() + db.session.commit() + + flash('Task has been approved successfully!', 'success') + return redirect(url_for('main.view_task', task_id=task.id)) # Or main.all_tasks diff --git a/todo_app/app/main/templates/main/all_tasks.html b/todo_app/app/main/templates/main/all_tasks.html new file mode 100644 index 0000000..f90c749 --- /dev/null +++ b/todo_app/app/main/templates/main/all_tasks.html @@ -0,0 +1,60 @@ + + + + + {{ title }} - Todo App + + + +

{{ title }}

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + + {% if tasks %} + + + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + + {% endfor %} + +
TitleDescriptionDue DateStatusCreated ByAssigned ToActions
{{ task.title }}{{ task.description }}{{ task.due_date.strftime('%Y-%m-%d') if task.due_date else 'N/A' }}{{ task.status }}{{ task.creator.username if task.creator else 'N/A' }}{{ task.assignee.username if task.assignee else 'N/A' }} + View + +
+ {% else %} +

No tasks found.

+ {% endif %} + +

Back to My Tasks

+ + + diff --git a/todo_app/app/main/templates/main/create_edit_task.html b/todo_app/app/main/templates/main/create_edit_task.html new file mode 100644 index 0000000..e69de29 diff --git a/todo_app/app/main/templates/main/index.html b/todo_app/app/main/templates/main/index.html new file mode 100644 index 0000000..e69de29 diff --git a/todo_app/app/main/templates/main/list_tasks.html b/todo_app/app/main/templates/main/list_tasks.html new file mode 100644 index 0000000..e69de29 diff --git a/todo_app/app/main/templates/main/view_task.html b/todo_app/app/main/templates/main/view_task.html new file mode 100644 index 0000000..e69de29 diff --git a/todo_app/app/models.py b/todo_app/app/models.py new file mode 100644 index 0000000..3c544fc --- /dev/null +++ b/todo_app/app/models.py @@ -0,0 +1,53 @@ +from datetime import datetime +from flask_sqlalchemy import SQLAlchemy + +# Assuming 'db' is initialized in app/__init__.py +from .. import db, bcrypt # Import bcrypt +# For now, let's create a SQLAlchemy instance here for standalone execution if needed. +# In a real Flask app, 'db' would come from the app's __init__.py +# db = SQLAlchemy() # bcrypt is already initialized in __init__ + +class User(db.Model): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password_hash = db.Column(db.String(128), nullable=False) + role = db.Column(db.String(20), nullable=False) # e.g., 'employee', 'manager' + + # Relationships to Tasks + assigned_tasks = db.relationship('Task', foreign_keys='Task.assignee_id', backref='assignee', lazy=True) + created_tasks = db.relationship('Task', foreign_keys='Task.creator_id', backref='creator', lazy=True) + approved_tasks_by_manager = db.relationship('Task', foreign_keys='Task.approved_by_id', backref='approved_by_manager', lazy=True) + + def set_password(self, password): + self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8') + + def check_password(self, password): + return bcrypt.check_password_hash(self.password_hash, password) + + def __repr__(self): + return f'' + +class Task(db.Model): + __tablename__ = 'tasks' + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(120), nullable=False) + description = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + due_date = db.Column(db.Date, nullable=True) + status = db.Column(db.String(20), nullable=False, default='pending') # e.g., 'pending', 'in progress', 'completed', 'approved' + + assignee_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + approved_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + + # Relationships back to User are defined by backrefs in User model: + # - assignee (from User.assigned_tasks) + # - creator (from User.created_tasks) + # - approved_by_manager (from User.approved_tasks_by_manager) + + def __repr__(self): + return f'' diff --git a/todo_app/app/routes.py b/todo_app/app/routes.py new file mode 100644 index 0000000..e69de29 diff --git a/todo_app/app/templates/base.html b/todo_app/app/templates/base.html new file mode 100644 index 0000000..e69de29 diff --git a/todo_app/config.py b/todo_app/config.py new file mode 100644 index 0000000..935c45b --- /dev/null +++ b/todo_app/config.py @@ -0,0 +1,39 @@ +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +basedir = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(basedir, '.env')) + +class Config: + """Base configuration class.""" + SECRET_KEY = os.environ.get('SECRET_KEY') or 'your_default_secret_key_here_CHANGE_THIS' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///' + os.path.join(basedir, 'app.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False + DEBUG = False # Default to False + +class DevelopmentConfig(Config): + """Development configuration.""" + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or 'sqlite:///' + os.path.join(basedir, 'dev.db') + +class TestingConfig(Config): + """Testing configuration.""" + TESTING = True + SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or 'sqlite:///:memory:' # Use in-memory for tests + WTF_CSRF_ENABLED = False # Disable CSRF for testing forms + +class ProductionConfig(Config): + """Production configuration.""" + # Ensure SECRET_KEY and DATABASE_URL are set in the environment for production + SECRET_KEY = os.environ.get('SECRET_KEY') # Must be set in environment + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') # Must be set in environment + # Add other production-specific settings here, e.g., logging, mail server, etc. + +# Dictionary to access configurations by name +config_by_name = dict( + dev=DevelopmentConfig, + test=TestingConfig, + prod=ProductionConfig, + default=DevelopmentConfig +) diff --git a/todo_app/requirements.txt b/todo_app/requirements.txt new file mode 100644 index 0000000..3fe4892 --- /dev/null +++ b/todo_app/requirements.txt @@ -0,0 +1,7 @@ +Flask +Flask-SQLAlchemy +Flask-Migrate +Flask-Login +Flask-Bcrypt +Flask-WTF +python-dotenv diff --git a/todo_app/run.py b/todo_app/run.py new file mode 100644 index 0000000..e69de29