Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ name: CI/CD Pipeline
on:
push:
branches:
- main
- changes
pull_request:
branches:
- main
- changes

jobs:
test:
Expand Down Expand Up @@ -70,9 +70,9 @@ jobs:
uses: docker/build-push-action@v5
with:
push: true
tags: woffee/wis_club_api:${{ github.sha }} # Uses the Git SHA for tagging, change 'woffee' to your DockerHub username
tags: wg99/wis_club_api:${{ github.sha }} # Uses the Git SHA for tagging, change 'woffee' to your DockerHub username
platforms: linux/amd64,linux/arm64 # Multi-platform support
cache-from: type=registry,ref=woffee/wis_club_api:cache # change 'woffee' to your DockerHub username
cache-from: type=registry,ref=wg99/wis_club_api:cache # change 'woffee' to your DockerHub username
cache-to: type=inline,mode=max

# - name: Scan the Docker image
Expand Down
19 changes: 19 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

def create_app(config_name='default'):
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///your_database.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db.init_app(app)

with app.app_context():
from .routes.user_routes import user_routes
app.register_blueprint(user_routes)

db.create_all()

return app
43 changes: 43 additions & 0 deletions app/controllers/user_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from flask import request, jsonify, g
from app.models.user import User
from app.models.notification import Notification
from app import db

def validate_profile_data(data):
if not data.get('name') or not data.get('bio') or not data.get('location'):
return False, "All fields (name, bio, location) are required."
return True, ""

def update_profile(request):
try:
user_id = g.user.id
updates = request.json
is_valid, message = validate_profile_data(updates)
if not is_valid:
return jsonify({'error': message}), 400
user = User.query.filter_by(id=user_id).first()
if not user:
return jsonify({'error': 'User not found'}), 404
for key, value in updates.items():
setattr(user, key, value)
db.session.commit()
return jsonify(user.serialize()), 200
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 400

def upgrade_to_professional(user_id):
try:
if g.user.role not in ['admin', 'manager']:
return jsonify({'error': 'Forbidden'}), 403
user = User.query.filter_by(id=user_id).first()
if not user:
return jsonify({'error': 'User not found'}), 404
user.professional_status = True
db.session.commit()
notification = Notification(user_id=user_id, message='Your account has been upgraded to professional status.')
notification.save()
return jsonify(user.serialize()), 200
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 400
11 changes: 11 additions & 0 deletions app/models/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from app import db

class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
message = db.Column(db.String(200))
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())

def save(self):
db.session.add(self)
db.session.commit()
23 changes: 23 additions & 0 deletions app/models/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from app import db

class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50))
bio = db.Column(db.String(200))
location = db.Column(db.String(100))
professional_status = db.Column(db.Boolean, default=False)
token = db.Column(db.String(200), unique=True)
role = db.Column(db.String(50), default='user')

def save(self):
db.session.add(self)
db.session.commit()

def serialize(self):
return {
'id': self.id,
'name': self.name,
'bio': self.bio,
'location': self.location,
'professional_status': self.professional_status
}
33 changes: 0 additions & 33 deletions app/routers/user_routes.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,3 @@
"""
This Python file is part of a FastAPI application, demonstrating user management functionalities including creating, reading,
updating, and deleting (CRUD) user information. It uses OAuth2 with Password Flow for security, ensuring that only authenticated
users can perform certain operations. Additionally, the file showcases the integration of FastAPI with SQLAlchemy for asynchronous
database operations, enhancing performance by non-blocking database calls.

The implementation emphasizes RESTful API principles, with endpoints for each CRUD operation and the use of HTTP status codes
and exceptions to communicate the outcome of operations. It introduces the concept of HATEOAS (Hypermedia as the Engine of
Application State) by including navigational links in API responses, allowing clients to discover other related operations dynamically.

OAuth2PasswordBearer is employed to extract the token from the Authorization header and verify the user's identity, providing a layer
of security to the operations that manipulate user data.

Key Highlights:
- Use of FastAPI's Dependency Injection system to manage database sessions and user authentication.
- Demonstrates how to perform CRUD operations in an asynchronous manner using SQLAlchemy with FastAPI.
- Implements HATEOAS by generating dynamic links for user-related actions, enhancing API discoverability.
- Utilizes OAuth2PasswordBearer for securing API endpoints, requiring valid access tokens for operations.
"""

from builtins import dict, int, len, str
from datetime import timedelta
Expand Down Expand Up @@ -125,21 +106,7 @@ async def delete_user(user_id: UUID, db: AsyncSession = Depends(get_db), token:

@router.post("/users/", response_model=UserResponse, status_code=status.HTTP_201_CREATED, tags=["User Management Requires (Admin or Manager Roles)"], name="create_user")
async def create_user(user: UserCreate, request: Request, db: AsyncSession = Depends(get_db), email_service: EmailService = Depends(get_email_service), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))):
"""
Create a new user.

This endpoint creates a new user with the provided information. If the email
already exists, it returns a 400 error. On successful creation, it returns the
newly created user's information along with links to related actions.

Parameters:
- user (UserCreate): The user information to create.
- request (Request): The request object.
- db (AsyncSession): The database session.

Returns:
- UserResponse: The newly created user's information along with navigation links.
"""
existing_user = await UserService.get_by_email(db, user.email)
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already exists")
Expand Down
43 changes: 43 additions & 0 deletions app/routes/user_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from flask import Blueprint, request, jsonify, g
from app.controllers.user_controller import update_profile, upgrade_to_professional
from app.models.user import User

user_routes = Blueprint('user_routes', __name__)

@user_routes.before_request
def load_user():
token = request.headers.get('Authorization')
if token:
token = token.replace('Bearer ', '')
user = User.query.filter_by(token=token).first()
if user:
g.user = user
else:
return jsonify({'error': 'Invalid token'}), 401
else:
g.user = None

# Route to update user profile fields
@user_routes.route('/api/users/profile', methods=['PUT'])
def update_user_profile():
if not g.user:
return jsonify({'error': 'Unauthorized'}), 401
return update_profile(request)

# Route for managers and admins to upgrade user to professional status
@user_routes.route('/api/users/<user_id>/upgrade', methods=['POST'])
def upgrade_user(user_id):
if not g.user:
return jsonify({'error': 'Unauthorized'}), 401
return upgrade_to_professional(user_id)

# Route to get user details by ID
@user_routes.route('/api/users/<user_id>', methods=['GET'])
def get_user(user_id):
if not g.user:
return jsonify({'error': 'Unauthorized'}), 401
user = User.query.filter_by(id=user_id).first()
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify(user.serialize()), 200

13 changes: 13 additions & 0 deletions app/static/js/profile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form');
form.addEventListener('submit', (event) => {
const name = document.getElementById('name').value;
const bio = document.getElementById('bio').value;
const location = document.getElementById('location').value;

if (!name || !bio || !location) {
event.preventDefault();
alert('All fields are required.');
}
});
});
28 changes: 28 additions & 0 deletions app/templates/admin.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ...existing code... -->
</head>
<body>
<!-- ...existing code... -->
<div class="admin">
<h1>Admin Panel</h1>
<form action="/upgrade" method="GET">
<label for="search">Search Users:</label>
<input type="text" id="search" name="search">
<button type="submit">Search</button>
</form>
<div id="user-list">
{% for user in users %}
<div class="user">
<p>{{ user.name }} ({{ user.email }})</p>
<form action="/api/users/{{ user.id }}/upgrade" method="POST">
<button type="submit">Upgrade to Professional</button>
</form>
</div>
{% endfor %}
</div>
</div>
<!-- ...existing code... -->
</body>
</html>
24 changes: 24 additions & 0 deletions app/templates/profile.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ...existing code... -->
</head>
<body>
<!-- ...existing code... -->
<div class="profile">
<h1>Profile</h1>
<form action="/api/users/profile" method="POST">
<input type="hidden" name="_method" value="PUT">
<label for="name">Name:</label>
<input type="text" id="name" name="name" value="{{ user.name }}">
<label for="bio">Bio:</label>
<textarea id="bio" name="bio">{{ user.bio }}</textarea>
<label for="location">Location:</label>
<input type="text" id="location" name="location" value="{{ user.location }}">
<button type="submit">Update Profile</button>
</form>
<p>Professional Status: {{ 'Yes' if user.professional_status else 'No' }}</p>
</div>
<!-- ...existing code... -->
</body>
</html>
11 changes: 11 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import os

class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False

class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///test.db'
WTF_CSRF_ENABLED = False
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,6 @@ typing_extensions==4.10.0
uvicorn==0.29.0
validators==0.24.0
markdown2
pyjwt
pyjwt
Flask
Flask-SQLAlchemy
Loading