Aplicación backend en Python para gestión Kanban multiusuario con API REST, filtros avanzados, autenticación JWT y manejo de proyectos, columnas y tareas.
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_jwt_extended import JWTManager, create_access_token, jwt_required, get_jwt_identity
from passlib.hash import pbkdf2_sha256 as sha256
from marshmallow import Schema, fields, ValidationError
from datetime import datetime, timedelta
# Configuración de la aplicación y base de datos
class Config:
SECRET_KEY = 'super-secret-key-change-me'
JWT_SECRET_KEY = 'jwt-super-secret-key-change-me'
SQLALCHEMY_DATABASE_URI = 'sqlite:///kanban.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
app = Flask(__name__)
app.config.from_object(Config)
# Inicialización de extensiones
db = SQLAlchemy(app)
jwt = JWTManager(app)
# MODELOS DE BASE DE DATOS
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)
projects = db.relationship('Project', backref='owner', lazy=True)
@staticmethod
def generate_hash(password):
return sha256.hash(password)
@staticmethod
def verify_hash(password, hash_):
return sha256.verify(password, hash_)
class Project(db.Model):
__tablename__ = 'projects'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
description = db.Column(db.Text, nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
columns = db.relationship('Column', backref='project', lazy=True, cascade='all, delete-orphan')
class Column(db.Model):
__tablename__ = 'columns'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False)
tasks = db.relationship('Task', backref='column', lazy=True, cascade='all, delete-orphan')
class Task(db.Model):
__tablename__ = 'tasks'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
status = db.Column(db.String(50), nullable=False) # ej. 'todo', 'in-progress', 'done'
priority = db.Column(db.Integer, default=3) # 1=alta, 2=media, 3=baja
due_date = db.Column(db.DateTime, nullable=True)
assigned_to = db.Column(db.String(80), nullable=True) # nombre de usuario asignado
column_id = db.Column(db.Integer, db.ForeignKey('columns.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ESQUEMAS MARSHMALLOW PARA VALIDACIÓN Y SERIALIZACIÓN
class UserSchema(Schema):
id = fields.Int(dump_only=True)
username = fields.Str(required=True)
password = fields.Str(load_only=True, required=True)
class ProjectSchema(Schema):
id = fields.Int(dump_only=True)
name = fields.Str(required=True)
description = fields.Str()
user_id = fields.Int(dump_only=True)
class ColumnSchema(Schema):
id = fields.Int(dump_only=True)
name = fields.Str(required=True)
project_id = fields.Int(required=True)
class TaskSchema(Schema):
id = fields.Int(dump_only=True)
title = fields.Str(required=True)
description = fields.Str()
status = fields.Str(required=True)
priority = fields.Int(validate=lambda x: 1 <= x <= 3)
due_date = fields.DateTime(allow_none=True)
assigned_to = fields.Str(allow_none=True)
column_id = fields.Int(required=True)
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
user_schema = UserSchema()
project_schema = ProjectSchema()
projects_schema = ProjectSchema(many=True)
column_schema = ColumnSchema()
columns_schema = ColumnSchema(many=True)
task_schema = TaskSchema()
tasks_schema = TaskSchema(many=True)
# RUTAS PARA AUTENTICACIÓN
@app.route('/register', methods=['POST'])
def register():
# Registro de usuario con nombre de usuario y contraseña
data = request.get_json()
try:
user_data = user_schema.load(data)
except ValidationError as err:
return jsonify(err.messages), 400
if User.query.filter_by(username=user_data['username']).first():
return jsonify({'message': 'Nombre de usuario ya existente'}), 400
new_user = User(
username=user_data['username'],
password_hash=User.generate_hash(user_data['password'])
)
db.session.add(new_user)
db.session.commit()
return jsonify({'message': 'Usuario registrado con éxito'}), 201
@app.route('/login', methods=['POST'])
def login():
# Login de usuario y generación de token JWT
data = request.get_json()
if not data or not data.get('username') or not data.get('password'):
return jsonify({'message': 'Nombre de usuario y contraseña requeridos'}), 400
user = User.query.filter_by(username=data['username']).first()
if not user or not User.verify_hash(data['password'], user.password_hash):
return jsonify({'message': 'Credenciales no válidas'}), 401
access_token = create_access_token(identity=user.id, expires_delta=timedelta(hours=1))
return jsonify({'access_token': access_token}), 200
# FUNCIONES DE APOYO
def get_current_user():
user_id = get_jwt_identity()
return User.query.get(user_id)
# RUTAS DE PROYECTOS
@app.route('/projects', methods=['POST'])
@jwt_required()
def create_project():
user = get_current_user()
data = request.get_json()
try:
project_data = project_schema.load(data)
except ValidationError as err:
return jsonify(err.messages), 400
new_project = Project(
name=project_data['name'],
description=project_data.get('description'),
owner=user
)
db.session.add(new_project)
db.session.commit()
return project_schema.jsonify(new_project), 201
@app.route('/projects', methods=['GET'])
@jwt_required()
def get_projects():
user = get_current_user()
projects = Project.query.filter_by(user_id=user.id).all()
return projects_schema.jsonify(projects), 200
@app.route('/projects/<int:project_id>', methods=['GET'])
@jwt_required()
def get_project(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
return project_schema.jsonify(project), 200
@app.route('/projects/<int:project_id>', methods=['PUT'])
@jwt_required()
def update_project(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
data = request.get_json()
try:
project_data = project_schema.load(data, partial=True)
except ValidationError as err:
return jsonify(err.messages), 400
if 'name' in project_data:
project.name = project_data['name']
if 'description' in project_data:
project.description = project_data['description']
db.session.commit()
return project_schema.jsonify(project), 200
@app.route('/projects/<int:project_id>', methods=['DELETE'])
@jwt_required()
def delete_project(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
db.session.delete(project)
db.session.commit()
return jsonify({'message': 'Proyecto eliminado'}), 200
# RUTAS DE COLUMNAS
@app.route('/projects/<int:project_id>/columns', methods=['POST'])
@jwt_required()
def create_column(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
data = request.get_json()
try:
column_data = column_schema.load({**data, 'project_id': project_id})
except ValidationError as err:
return jsonify(err.messages), 400
new_column = Column(name=column_data['name'], project=project)
db.session.add(new_column)
db.session.commit()
return column_schema.jsonify(new_column), 201
@app.route('/projects/<int:project_id>/columns', methods=['GET'])
@jwt_required()
def get_columns(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
columns = Column.query.filter_by(project_id=project_id).all()
return columns_schema.jsonify(columns), 200
@app.route('/columns/<int:column_id>', methods=['PUT'])
@jwt_required()
def update_column(column_id):
user = get_current_user()
column = Column.query.get(column_id)
if not column or column.project.user_id != user.id:
return jsonify({'message': 'Columna no encontrada o acceso denegado'}), 404
data = request.get_json()
try:
column_data = column_schema.load(data, partial=True)
except ValidationError as err:
return jsonify(err.messages), 400
if 'name' in column_data:
column.name = column_data['name']
db.session.commit()
return column_schema.jsonify(column), 200
@app.route('/columns/<int:column_id>', methods=['DELETE'])
@jwt_required()
def delete_column(column_id):
user = get_current_user()
column = Column.query.get(column_id)
if not column or column.project.user_id != user.id:
return jsonify({'message': 'Columna no encontrada o acceso denegado'}), 404
db.session.delete(column)
db.session.commit()
return jsonify({'message': 'Columna eliminada'}), 200
# RUTAS DE TAREAS
@app.route('/columns/<int:column_id>/tasks', methods=['POST'])
@jwt_required()
def create_task(column_id):
user = get_current_user()
column = Column.query.get(column_id)
if not column or column.project.user_id != user.id:
return jsonify({'message': 'Columna no encontrada o acceso denegado'}), 404
data = request.get_json()
try:
task_data = task_schema.load({**data, 'column_id': column_id})
except ValidationError as err:
return jsonify(err.messages), 400
new_task = Task(
title=task_data['title'],
description=task_data.get('description'),
status=task_data['status'],
priority=task_data.get('priority', 3),
due_date=task_data.get('due_date'),
assigned_to=task_data.get('assigned_to'),
column=column
)
db.session.add(new_task)
db.session.commit()
return task_schema.jsonify(new_task), 201
@app.route('/columns/<int:column_id>/tasks', methods=['GET'])
@jwt_required()
def get_tasks(column_id):
user = get_current_user()
column = Column.query.get(column_id)
if not column or column.project.user_id != user.id:
return jsonify({'message': 'Columna no encontrada o acceso denegado'}), 404
# Filtros avanzados
status = request.args.get('status')
assigned_to = request.args.get('assigned_to')
priority = request.args.get('priority', type=int)
due_date_from = request.args.get('due_date_from')
due_date_to = request.args.get('due_date_to')
query = Task.query.filter_by(column_id=column_id)
if status:
query = query.filter(Task.status == status)
if assigned_to:
query = query.filter(Task.assigned_to == assigned_to)
if priority:
query = query.filter(Task.priority == priority)
if due_date_from:
try:
dt_from = datetime.fromisoformat(due_date_from)
query = query.filter(Task.due_date >= dt_from)
except ValueError:
return jsonify({'message': 'Formato de fecha due_date_from no válido'}), 400
if due_date_to:
try:
dt_to = datetime.fromisoformat(due_date_to)
query = query.filter(Task.due_date <= dt_to)
except ValueError:
return jsonify({'message': 'Formato de fecha due_date_to no válido'}), 400
tasks = query.all()
return tasks_schema.jsonify(tasks), 200
@app.route('/tasks/<int:task_id>', methods=['GET'])
@jwt_required()
def get_task(task_id):
user = get_current_user()
task = Task.query.get(task_id)
if not task or task.column.project.user_id != user.id:
return jsonify({'message': 'Tarea no encontrada o acceso denegado'}), 404
return task_schema.jsonify(task), 200
@app.route('/tasks/<int:task_id>', methods=['PUT'])
@jwt_required()
def update_task(task_id):
user = get_current_user()
task = Task.query.get(task_id)
if not task or task.column.project.user_id != user.id:
return jsonify({'message': 'Tarea no encontrada o acceso denegado'}), 404
data = request.get_json()
try:
task_data = task_schema.load(data, partial=True)
except ValidationError as err:
return jsonify(err.messages), 400
for key, value in task_data.items():
setattr(task, key, value)
db.session.commit()
return task_schema.jsonify(task), 200
@app.route('/tasks/<int:task_id>', methods=['DELETE'])
@jwt_required()
def delete_task(task_id):
user = get_current_user()
task = Task.query.get(task_id)
if not task or task.column.project.user_id != user.id:
return jsonify({'message': 'Tarea no encontrada o acceso denegado'}), 404
db.session.delete(task)
db.session.commit()
return jsonify({'message': 'Tarea eliminada'}), 200
# INICIO DE LA APLICACIÓN
if __name__ == '__main__':
with app.app_context():
db.create_all() # Crea las tablas si no existen
app.run(debug=True)# Ejemplo completo para probar la aplicación Flask de gestión Kanban
# Incluye creación de la base de datos, ejecución del servidor y una petición de prueba
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_jwt_extended import JWTManager, create_access_token, jwt_required, get_jwt_identity
from passlib.hash import pbkdf2_sha256 as sha256
from marshmallow import Schema, fields, ValidationError
from datetime import datetime, timedelta
import json
# Código original copiado exactamente
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_jwt_extended import JWTManager, create_access_token, jwt_required, get_jwt_identity
from passlib.hash import pbkdf2_sha256 as sha256
from marshmallow import Schema, fields, ValidationError
from datetime import datetime, timedelta
# Configuración de la aplicación y base de datos
class Config:
SECRET_KEY = 'super-secret-key-change-me'
JWT_SECRET_KEY = 'jwt-super-secret-key-change-me'
SQLALCHEMY_DATABASE_URI = 'sqlite:///kanban.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
jwt = JWTManager(app)
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)
projects = db.relationship('Project', backref='owner', lazy=True)
@staticmethod
def generate_hash(password):
return sha256.hash(password)
@staticmethod
def verify_hash(password, hash_):
return sha256.verify(password, hash_)
class Project(db.Model):
__tablename__ = 'projects'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
description = db.Column(db.Text, nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
columns = db.relationship('Column', backref='project', lazy=True, cascade='all, delete-orphan')
class Column(db.Model):
__tablename__ = 'columns'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False)
tasks = db.relationship('Task', backref='column', lazy=True, cascade='all, delete-orphan')
class Task(db.Model):
__tablename__ = 'tasks'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
status = db.Column(db.String(50), nullable=False) # ej. 'todo', 'in-progress', 'done'
priority = db.Column(db.Integer, default=3) # 1=alta, 2=media, 3=baja
due_date = db.Column(db.DateTime, nullable=True)
assigned_to = db.Column(db.String(80), nullable=True) # nombre de usuario asignado
column_id = db.Column(db.Integer, db.ForeignKey('columns.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class UserSchema(Schema):
id = fields.Int(dump_only=True)
username = fields.Str(required=True)
password = fields.Str(load_only=True, required=True)
class ProjectSchema(Schema):
id = fields.Int(dump_only=True)
name = fields.Str(required=True)
description = fields.Str()
user_id = fields.Int(dump_only=True)
class ColumnSchema(Schema):
id = fields.Int(dump_only=True)
name = fields.Str(required=True)
project_id = fields.Int(required=True)
class TaskSchema(Schema):
id = fields.Int(dump_only=True)
title = fields.Str(required=True)
description = fields.Str()
status = fields.Str(required=True)
priority = fields.Int(validate=lambda x: 1 <= x <= 3)
due_date = fields.DateTime(allow_none=True)
assigned_to = fields.Str(allow_none=True)
column_id = fields.Int(required=True)
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
user_schema = UserSchema()
project_schema = ProjectSchema()
projects_schema = ProjectSchema(many=True)
column_schema = ColumnSchema()
columns_schema = ColumnSchema(many=True)
task_schema = TaskSchema()
tasks_schema = TaskSchema(many=True)
@app.route('/register', methods=['POST'])
def register():
data = request.get_json()
try:
user_data = user_schema.load(data)
except ValidationError as err:
return jsonify(err.messages), 400
if User.query.filter_by(username=user_data['username']).first():
return jsonify({'message': 'Nombre de usuario ya existente'}), 400
new_user = User(
username=user_data['username'],
password_hash=User.generate_hash(user_data['password'])
)
db.session.add(new_user)
db.session.commit()
return jsonify({'message': 'Usuario registrado con éxito'}), 201
@app.route('/login', methods=['POST'])
def login():
data = request.get_json()
if not data or not data.get('username') or not data.get('password'):
return jsonify({'message': 'Nombre de usuario y contraseña requeridos'}), 400
user = User.query.filter_by(username=data['username']).first()
if not user or not User.verify_hash(data['password'], user.password_hash):
return jsonify({'message': 'Credenciales no válidas'}), 401
access_token = create_access_token(identity=user.id, expires_delta=timedelta(hours=1))
return jsonify({'access_token': access_token}), 200
def get_current_user():
user_id = get_jwt_identity()
return User.query.get(user_id)
@app.route('/projects', methods=['POST'])
@jwt_required()
def create_project():
user = get_current_user()
data = request.get_json()
try:
project_data = project_schema.load(data)
except ValidationError as err:
return jsonify(err.messages), 400
new_project = Project(
name=project_data['name'],
description=project_data.get('description'),
owner=user
)
db.session.add(new_project)
db.session.commit()
return project_schema.jsonify(new_project), 201
@app.route('/projects', methods=['GET'])
@jwt_required()
def get_projects():
user = get_current_user()
projects = Project.query.filter_by(user_id=user.id).all()
return projects_schema.jsonify(projects), 200
@app.route('/projects/<int:project_id>', methods=['GET'])
@jwt_required()
def get_project(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
return project_schema.jsonify(project), 200
@app.route('/projects/<int:project_id>', methods=['PUT'])
@jwt_required()
def update_project(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
data = request.get_json()
try:
project_data = project_schema.load(data, partial=True)
except ValidationError as err:
return jsonify(err.messages), 400
if 'name' in project_data:
project.name = project_data['name']
if 'description' in project_data:
project.description = project_data['description']
db.session.commit()
return project_schema.jsonify(project), 200
@app.route('/projects/<int:project_id>', methods=['DELETE'])
@jwt_required()
def delete_project(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
db.session.delete(project)
db.session.commit()
return jsonify({'message': 'Proyecto eliminado'}), 200
@app.route('/projects/<int:project_id>/columns', methods=['POST'])
@jwt_required()
def create_column(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
data = request.get_json()
try:
column_data = column_schema.load({**data, 'project_id': project_id})
except ValidationError as err:
return jsonify(err.messages), 400
new_column = Column(name=column_data['name'], project=project)
db.session.add(new_column)
db.session.commit()
return column_schema.jsonify(new_column), 201
@app.route('/projects/<int:project_id>/columns', methods=['GET'])
@jwt_required()
def get_columns(project_id):
user = get_current_user()
project = Project.query.filter_by(id=project_id, user_id=user.id).first()
if not project:
return jsonify({'message': 'Proyecto no encontrado'}), 404
columns = Column.query.filter_by(project_id=project_id).all()
return columns_schema.jsonify(columns), 200
@app.route('/columns/<int:column_id>', methods=['PUT'])
@jwt_required()
def update_column(column_id):
user = get_current_user()
column = Column.query.get(column_id)
if not column or column.project.user_id != user.id:
return jsonify({'message': 'Columna no encontrada o acceso denegado'}), 404
data = request.get_json()
try:
column_data = column_schema.load(data, partial=True)
except ValidationError as err:
return jsonify(err.messages), 400
if 'name' in column_data:
column.name = column_data['name']
db.session.commit()
return column_schema.jsonify(column), 200
@app.route('/columns/<int:column_id>', methods=['DELETE'])
@jwt_required()
def delete_column(column_id):
user = get_current_user()
column = Column.query.get(column_id)
if not column or column.project.user_id != user.id:
return jsonify({'message': 'Columna no encontrada o acceso denegado'}), 404
db.session.delete(column)
db.session.commit()
return jsonify({'message': 'Columna eliminada'}), 200
@app.route('/columns/<int:column_id>/tasks', methods=['POST'])
@jwt_required()
def create_task(column_id):
user = get_current_user()
column = Column.query.get(column_id)
if not column or column.project.user_id != user.id:
return jsonify({'message': 'Columna no encontrada o acceso denegado'}), 404
data = request.get_json()
try:
task_data = task_schema.load({**data, 'column_id': column_id})
except ValidationError as err:
return jsonify(err.messages), 400
new_task = Task(
title=task_data['title'],
description=task_data.get('description'),
status=task_data['status'],
priority=task_data.get('priority', 3),
due_date=task_data.get('due_date'),
assigned_to=task_data.get('assigned_to'),
column=column
)
db.session.add(new_task)
db.session.commit()
return task_schema.jsonify(new_task), 201
@app.route('/columns/<int:column_id>/tasks', methods=['GET'])
@jwt_required()
def get_tasks(column_id):
user = get_current_user()
column = Column.query.get(column_id)
if not column or column.project.user_id != user.id:
return jsonify({'message': 'Columna no encontrada o acceso denegado'}), 404
status = request.args.get('status')
assigned_to = request.args.get('assigned_to')
priority = request.args.get('priority', type=int)
due_date_from = request.args.get('due_date_from')
due_date_to = request.args.get('due_date_to')
query = Task.query.filter_by(column_id=column_id)
if status:
query = query.filter(Task.status == status)
if assigned_to:
query = query.filter(Task.assigned_to == assigned_to)
if priority:
query = query.filter(Task.priority == priority)
if due_date_from:
try:
dt_from = datetime.fromisoformat(due_date_from)
query = query.filter(Task.due_date >= dt_from)
except ValueError:
return jsonify({'message': 'Formato de fecha due_date_from no válido'}), 400
if due_date_to:
try:
dt_to = datetime.fromisoformat(due_date_to)
query = query.filter(Task.due_date <= dt_to)
except ValueError:
return jsonify({'message': 'Formato de fecha due_date_to no válido'}), 400
tasks = query.all()
return tasks_schema.jsonify(tasks), 200
# Código adicional para inicializar la base de datos y ejecutar el servidor
if __name__ == '__main__':
# Crear las tablas en la base de datos
with app.app_context():
db.create_all()
# Ejecutar el servidor Flask
app.run(debug=True)
# Comentarios:
# 1. Este script es un ejemplo mínimo y completo para probar la API REST de Kanban.
# 2. Se crea la base de datos SQLite localmente y se levantan todas las rutas.
# 3. Para probar, se puede usar curl o Postman para registrar un usuario, loguearse y crear proyectos, columnas y tareas.
# 4. El servidor corre en modo debug para facilitar pruebas y desarrollo.Hardware: No se requiere ningún componente hardware (aplicación backend del lado servidor).
Software: Python 3.8+, Flask para el framework web, Flask-JWT-Extended para autenticación JWT, Flask-SQLAlchemy para ORM, SQLite como base de datos por defecto (configurable), Marshmallow para serialización y validación.
Configuración: Archivo de configuración para claves secretas JWT y URI de base de datos (config.py).
Dependencias: flask, flask_sqlalchemy, flask_jwt_extended, marshmallow, passlib (para hashing de contraseñas).
Estructura API REST: endpoints para registro/login de usuarios, CRUD de proyectos, columnas y tareas, filtros avanzados para consultas de tareas.
Seguridad: autenticación JWT, control de acceso a recursos basado en usuario propietario.
Base de datos: modelos User, Project, Column, Task con relaciones uno-a-muchos y campos para estado, prioridad, fechas, asignados.