Compare commits

...

22 Commits
lab1 ... master

Author SHA1 Message Date
ІО-23 Шмуляр Олег c644c472e3
merging dev branch into master as a stable release 2024-12-29 18:33:08 +02:00
ІО-23 Шмуляр Олег 731b79c865
clean up debugging context 2024-12-29 18:25:30 +02:00
ІО-23 Шмуляр Олег f78fa33397
secure all required endpoints with JWT 2024-12-29 18:21:37 +02:00
ІО-23 Шмуляр Олег 729498db11
add dependencies for JWT support 2024-12-29 18:20:18 +02:00
ІО-23 Шмуляр Олег 589700e4d3
improve test performance 2024-12-29 11:45:05 +02:00
ІО-23 Шмуляр Олег a42d94779c hotfix: balance_up not recognizing uuid as a valid argument 2024-12-28 19:26:27 +02:00
ІО-23 Шмуляр Олег bba801f2d2
add balance entity
Add respecting schema, model and adapt all endpoints to utilize it.
Add two new endpoints for increasing and checking the balance.
2024-12-28 18:50:46 +02:00
ІО-23 Шмуляр Олег 32fd20b3ae migrate user, category and record API to db 2024-12-28 15:10:29 +02:00
ІО-23 Шмуляр Олег b96ba3eb34 add automated testing script for user table 2024-12-28 12:50:32 +02:00
ІО-23 Шмуляр Олег c2104fd9cc finish user API migration to DB 2024-12-28 12:49:54 +02:00
ІО-23 Шмуляр Олег 7ef08e64c2 migrate post user and get users api 2024-12-27 23:23:21 +02:00
ІО-23 Шмуляр Олег 9f5c2505de add environment files to .gitignore 2024-12-27 21:52:03 +02:00
ІО-23 Шмуляр Олег 9fe9a112e7 add database and marshmallow entities to app 2024-12-27 21:51:19 +02:00
ІО-23 Шмуляр Олег a5c5c1850d add database container to docker-compose.yaml 2024-12-27 21:49:24 +02:00
ІО-23 Шмуляр Олег 2fcb8aa276 bump dependencies for application 2024-12-27 21:44:09 +02:00
ІО-23 Шмуляр Олег cfe3fa5fc7
add variant for further development 2024-12-23 19:21:30 +02:00
ІО-23 Шмуляр Олег 1ad5a7587b
add record-related endpoints 2024-10-28 20:10:04 +02:00
ІО-23 Шмуляр Олег e6954e5710
add category-related endpoints 2024-10-28 18:18:28 +02:00
ІО-23 Шмуляр Олег 5d9da36bc5
add workaround for postman flow
Add endpoint specifically designed to make sure postman flow will
execute correctly on any run regardless of what previous changes have
been done to the server database.
2024-10-28 17:27:34 +02:00
ІО-23 Шмуляр Олег 3390b2e374
add user-related endpoints 2024-10-28 17:19:47 +02:00
ІО-23 Шмуляр Олег 8b7e93bd67
use volumes instead of copying application files
This change drastically reduces the amount of rebuilding required during
application development and debugging stages.
2024-10-28 14:08:23 +02:00
ІО-23 Шмуляр Олег fc6775ed2e
set __pycache__/ directories to be ignored by git 2024-10-28 14:05:30 +02:00
9 changed files with 644 additions and 3 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.env

View File

@ -9,6 +9,4 @@ COPY ./requirements.txt /app/requirements.txt
RUN python -m pip install -r requirements.txt
COPY ./app/ /app/app/
CMD flask run -h 0.0.0.0 -p $PORT

View File

@ -13,3 +13,7 @@ docker-compose up
```
That should be enough to get the server up and running.
## Lab3 specifics
Variant for functionality extension: 27 % 3 = 0 => Income accountance

View File

@ -1,7 +1,83 @@
from flask import Flask
from flask import Flask, request, jsonify
from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required, JWTManager
import time
import json
import uuid
import datetime
import os
from hashlib import sha256
from marshmallow import Schema, fields
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config.from_pyfile('config.py', silent=True)
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY")
db = SQLAlchemy(app)
jwt = JWTManager(app)
class UserModel(db.Model):
__tablename__ = "user"
uuid = db.Column(db.String(32), unique=True, primary_key=True, nullable=False)
name = db.Column(db.String(64), nullable=False)
password = db.Column(db.String(64), nullable=False)
bal_uuid = db.Column(db.String(32), db.ForeignKey('balance.uuid'))
class CategoryModel(db.Model):
__tablename__ = "category"
uuid = db.Column(db.String(32), unique=True, primary_key=True, nullable=False)
name = db.Column(db.String(64), nullable=False)
class RecordModel(db.Model):
__tablename__ = "record"
uuid = db.Column(db.String(32), primary_key=True, nullable=False)
user_uuid = db.Column(db.String(32), db.ForeignKey('user.uuid'))
cat_uuid = db.Column(db.String(32), db.ForeignKey('category.uuid'))
date = db.Column(db.Date)
amount = db.Column(db.Integer)
class BalanceModel(db.Model):
__tablename__ = "balance"
uuid = db.Column(db.String(32), primary_key=True, nullable=False)
value = db.Column(db.Integer, nullable=False)
class UserSchema(Schema):
uuid = fields.Str()
name = fields.Str()
password = fields.Str()
bal_uuid = fields.Str()
class CategorySchema(Schema):
uuid = fields.Str()
name = fields.Str()
class RecordSchema(Schema):
uuid = fields.Str()
user_uuid = fields.Str()
cat_uuid = fields.Str()
date = fields.Date('iso')
amount = fields.Integer()
class BalanceSchema(Schema):
uuid = fields.Str()
value = fields.Integer()
user_schema = UserSchema()
users_schema = UserSchema(many = True)
category_schema = CategorySchema()
categories_schema = CategorySchema(many = True)
record_schema = RecordSchema()
records_schema = RecordSchema(many = True)
balance_schema = BalanceSchema()
# "migration"
with app.app_context():
db.create_all()
@app.route("/healthcheck")
def ep_healthcheck():
@ -9,3 +85,309 @@ def ep_healthcheck():
"date": time.strftime('%Y.%m.%d %H:%M:%S'),
"status": "OK"
}
@app.route("/reset_users_because_postman_is_dumb_like_that")
def ep_reset():
db.session.query(RecordModel).delete()
db.session.query(CategoryModel).delete()
db.session.query(UserModel).delete()
db.session.query(BalanceModel).delete()
db.session.commit()
return {}, 200
@app.route("/users", methods = ["GET"])
@jwt_required()
def ep_users_get():
result = db.session.query(UserModel).all()
return users_schema.dumps(result)
@app.route("/user", methods = ["POST"])
def ep_user_post():
name = request.json.get('name', None)
password = request.json.get('password', None)
pass_hashed = sha256(password.encode("UTF-8")).digest().hex()
b = BalanceModel(uuid=uuid.uuid4().hex, value=0)
u_uuid = uuid.uuid4().hex
bal_uuid = b.uuid
try:
_ = user_schema.load({'uuid': u_uuid, 'name': name,
'password': pass_hashed, 'bal_uuid': bal_uuid})
except Exception as e:
return {}, 403
u = UserModel(uuid=u_uuid, name=name,
password=pass_hashed, bal_uuid=bal_uuid)
at = create_access_token(identity = json.dumps({'uuid': u.uuid, 'bal_uuid': u.bal_uuid}))
try:
db.session.add(b)
db.session.add(u)
db.session.commit()
except Exception as e:
db.session.rollback()
return {}, 403
return jsonify(access_token = at), 200
@app.route("/user", methods = ["GET"])
def ep_user_get():
name = request.json.get('user', None)
password = request.json.get('password', None)
pass_hashed = sha256(password.encode("UTF-8")).digest().hex()
u = db.session.query(UserModel).filter(UserModel.name == name).one_or_none()
if not u:
return {}, 404
if u.password != pass_hashed:
return {"message": "Wrong password."}, 401
at = create_access_token(identity = json.dumps({'uuid': u.uuid, 'bal_uuid': u.bal_uuid}))
return jsonify(access_token = at), 200
@app.route("/user", methods = ["DELETE"])
@jwt_required()
def ep_user_delete():
current_user = json.loads(get_jwt_identity())
try:
db.session.query(UserModel).filter(UserModel.uuid == current_user['uuid']).delete()
db.session.query(BalanceModel).filter(BalanceModel.uuid == current_user['bal_uuid']).delete()
db.session.commit()
except Exception as e:
db.session.rollback()
return {}, 403
return {"message": "Success."}, 200
@app.route("/category", methods = ["GET"])
@jwt_required()
def ep_category_get():
body = request.json
if 'uuid' in body:
result = db.session.query(CategoryModel).filter(CategoryModel.uuid == body['uuid']).all()
if len(result) == 1:
return user_schema.dumps(result[0]), 200
else:
return {}, 404
else:
return {}, 403
@app.route("/category", methods = ["POST"])
@jwt_required()
def ep_category_post():
body = request.json
if not body:
return {}, 403
if 'uuid' in body:
return {}, 403
body.update({'uuid': uuid.uuid4().hex})
try:
_ = category_schema.load(body)
except ValidationError as e:
return {}, 403
c = CategoryModel(**body)
try:
db.session.add(c)
db.session.commit()
except Exception as e:
db.session.rollback()
return {}, 403
return jsonify(category_schema.load(body)), 200
@app.route("/category", methods = ["DELETE"])
@jwt_required()
def ep_category_delete():
body = request.json
if 'uuid' not in body:
return {}, 403
cat_id = body['uuid']
try:
result = db.session.query(CategoryModel).filter(CategoryModel.uuid == cat_id).all()
except Exception as e:
return {}, 403
if len(result) == 0:
return {}, 404
try:
db.session.query(CategoryModel).filter(CategoryModel.uuid == cat_id).delete()
db.session.commit()
except Exception as e:
return {}, 403
return category_schema.dumps(result[0]), 200
@app.route("/record/<record_id>", methods = ["GET"])
@jwt_required()
def ep_record_get(record_id):
current_user = json.loads(get_jwt_identity())
result = db.session.query(RecordModel).filter(RecordModel.uuid == record_id).one_or_none()
if result and result.user_uuid == current_user['uuid']:
return user_schema.dumps(result), 200
else:
return {}, 403
@app.route("/record", methods = ["GET"])
@jwt_required()
def ep_record_get_filtered():
r = db.session.query(RecordModel)
filtered = False
if 'user_id' in request.json:
r = r.filter(RecordModel.user_uuid == request.json['user_id'])
filtered = True
elif 'user_uuid' in request.json:
r = r.filter(RecordModel.user_uuid == request.json['user_uuid'])
filtered = True
if 'cat_id' in request.json:
r = r.filter(RecordModel.cat_uuid == request.json['cat_id'])
filtered = True
if 'cat_uuid' in request.json:
r = r.filter(RecordModel.cat_uuid == request.json['cat_uuid'])
filtered = True
if filtered:
return records_schema.dumps(r.all())
else:
return [], 403
@app.route("/record/<record_id>", methods = ["DELETE"])
@jwt_required()
def ep_record_del(record_id):
current_user = json.loads(get_jwt_identity())
try:
result = db.session.query(RecordModel).filter(RecordModel.uuid == record_id).one_or_none()
except Exception as e:
return {}, 403
if result and result.user_uuid == current_user['uuid']:
db.session.query(RecordModel).filter(RecordModel.uuid == record_id).delete()
db.session.commit()
else:
return {}, 401
return record_schema.dumps(result), 200
@app.route("/record", methods = ["POST"])
@jwt_required()
def ep_record_post():
current_user = json.loads(get_jwt_identity())
amount = request.json.get('amount', None)
category = request.json.get('category', None)
if not all([amount, category]):
return {}, 403
u_uuid = uuid.uuid4().hex
r_time = time.strftime("%Y-%m-%d")
try:
_ = record_schema.load({'amount': amount, "cat_uuid": category, "uuid": u_uuid,
'user_uuid': current_user['uuid'], 'date': r_time})
except Exception as e:
return {}, 400
r = RecordModel(amount=amount, cat_uuid=category, uuid=u_uuid, user_uuid=current_user['uuid'], date=r_time)
v = db.session \
.query(BalanceModel) \
.filter(BalanceModel.uuid == current_user['bal_uuid']) \
.one_or_none() \
.value
BalanceModel \
.metadata.tables.get("balance") \
.update() \
.where(BalanceModel.metadata.tables.get("balance").c.uuid == current_user['bal_uuid']) \
.values(value = v - amount)
try:
db.session.add(r)
db.session.commit()
except Exception as e:
db.session.rollback()
return {}, 403
return {'uuid': r.uuid}, 200
@app.route("/balance_up", methods = ["POST"])
@jwt_required()
def ep_balance_up():
current_user = json.loads(get_jwt_identity())
amount = request.json.get('amount', None)
try:
v = db.session \
.query(BalanceModel) \
.filter(BalanceModel.uuid == current_user['bal_uuid']) \
.one_or_none() \
.value
BalanceModel.metadata.tables.get("balance").update().where(BalanceModel.metadata.tables.get("balance").c.uuid == current_user['bal_uuid']).values(value = v + amount)
except Exception as e:
return {}, 407
return {}, 200
@app.route("/balance", methods = ["GET"])
@jwt_required()
def ep_balance_get():
current_user = json.loads(get_jwt_identity())
result = db.session.query(BalanceModel).filter(BalanceModel.uuid == current_user['bal_uuid']).one_or_none()
return balance_schema.dumps(result), 200
@jwt.expired_token_loader
def expired_token_callback(jwt_header, jwt_payload):
return (
jsonify({"message": "The token has expired.", "error": "token_expired"}),
401,
)
@jwt.invalid_token_loader
def invalid_token_callback(error):
return (
jsonify(
{"message": "Signature verification failed.", "error": "invalid_token"}
),
401,
)
@jwt.unauthorized_loader
def missing_token_callback(error):
return (
jsonify(
{
"description": "Request does not contain an access token.",
"error": "authorization_required",
}
),
401,
)

7
app/config.py Normal file
View File

@ -0,0 +1,7 @@
import os
PROPAGATE_EXCEPTIONS = True
SQLALCHEMY_DATABASE_URI = f'postgresql://{os.environ["POSTGRES_USER"]}:{os.environ["POSTGRES_PASSWORD"]}@db:5432/accountance'
SQLALCHEMY_TRACK_MODIFICATIONS = False
API_TITLE = "Finance REST API"
API_VERSION = 'v1'

162
app/local_db.py Normal file
View File

@ -0,0 +1,162 @@
import uuid
import time
class LocalDB:
def __init__(self):
self.users = {}
self.categories = {}
self.records = {}
def reset(self):
for k, v in list(self.users.items()):
if v == "test1":
del self.users[k]
for k, v in list(self.categories.items()):
if v == "test2":
del self.categories[k]
def get_users(self):
return self.users
def get_user(self, uid):
if uid in self.users:
return {
'uuid': uid,
'name': self.users[uid]
}
else:
return {}
def add_user(self, name):
if not name:
return {}
if name in set(self.users.values()):
return {}
new_uuid = uuid.uuid4().hex
self.users[new_uuid] = name
return {
'uuid': new_uuid,
'name': name
}
def del_user(self, uid):
if uid in self.users:
name = self.users[uid]
del self.users[uid]
return {
'uuid': uid,
'name': name
}
else:
return {}
def get_category(self, uid):
if uid in self.categories:
return {
'uuid': uid,
'name': self.categories[uid]
}
else:
return {}
def add_category(self, name):
if not name:
return {}
if name in set(self.categories.values()):
return {}
new_uuid = uuid.uuid4().hex
self.categories[new_uuid] = name
return {
'uuid': new_uuid,
'name': name
}
def del_category(self, uid):
if uid in self.categories:
name = self.categories[uid]
del self.categories[uid]
return {
'uuid': uid,
'name': name
}
else:
return {}
def get_record(self, uid):
if uid in self.records:
r = self.records[uid]
return {
'uuid': uid,
'user_id': r['user_id'],
'cat_id': r['cat_id'],
'date': r['date'],
'amount': r['amount']
}
else:
return {}
def filter_records(self, filters):
results = []
for k, v in self.records.items():
if 'user_id' in filters and v['user_id'] != filters['user_id']:
continue
if 'cat_id' in filters and v['cat_id'] != filters['cat_id']:
continue
results.append({
'uuid': k,
'user_id': v['user_id'],
'cat_id': v['cat_id'],
'date': v['date'],
'amount': v['amount']
})
return results
def add_record(self, user_id, cat_id, amount):
if user_id not in self.users:
return {}
if cat_id not in self.categories:
return {}
new_uuid = uuid.uuid4().hex
self.records[new_uuid] = {
'user_id': user_id,
'cat_id': cat_id,
'date': time.time(),
'amount': amount
}
return {
'uuid': new_uuid,
'user_id': user_id,
'cat_id': cat_id,
'date': time.time(),
'amount': amount
}
def del_record(self, uid):
if uid in self.records:
r = self.records[uid]
del self.records[uid]
return {
'uuid': uid,
'user_id': r['user_id'],
'cat_id': r['cat_id'],
'date': r['date'],
'amount': r['amount']
}
else:
return {}

View File

@ -10,3 +10,16 @@ services:
PORT: "12402"
ports:
- "12402:12402"
volumes:
- ./app:/app/app
env_file:
- db.env
- app.env
depends_on:
- db
db:
restart: unless-stopped
image: postgres:17.2-alpine3.21
env_file:
- db.env

View File

@ -1,7 +1,22 @@
alembic==1.14.0
apispec==6.8.0
blinker==1.8.2
click==8.1.7
Flask==3.0.3
Flask-JWT-Extended==4.7.1
Flask-Migrate==4.0.7
flask-smorest==0.45.0
Flask-SQLAlchemy==3.1.1
greenlet==3.1.1
itsdangerous==2.2.0
Jinja2==3.1.4
Mako==1.3.8
MarkupSafe==2.1.5
marshmallow==3.23.2
packaging==24.2
psycopg2-binary==2.9.10
PyJWT==2.10.1
SQLAlchemy==2.0.36
typing_extensions==4.12.2
webargs==8.6.0
Werkzeug==3.0.4

58
tests/lab3/user_test.sh Executable file
View File

@ -0,0 +1,58 @@
#!/bin/sh
HOST=$1
if [ -z HOST ]; then
echo "Host not specified, exiting"
exit 1;
fi;
echo -n 'Resetting DB...'
curl -X GET -f -s \
http://$HOST:12402/reset_users_because_postman_is_dumb_like_that > /dev/null
if [ $? -ne 0 ]; then
echo 'Exiting due to previous error'
exit 1
fi
echo ' Done.'
echo -n 'Creating users...'
for i in $(seq 1); do
curl -X POST -f -s \
--data "{\"name\": \"hi$i\"}" \
--header "Content-Type: application/json" \
http://$HOST:12402/user > /dev/null;
if [ $? -ne 0 ]; then
echo 'Exiting due to previous error'
exit 1
fi
done
echo ' Done.'
echo -n 'Getting user UUID to delete...'
DELETION_UUID=$(curl -X GET -f -s \
http://$HOST:12402/users \
| jq .[0].uuid);
if [ $? -ne 0 ]; then
echo 'Exiting due to previous error'
exit 1
fi
DELETION_UUID=${DELETION_UUID#\"}
DELETION_UUID=${DELETION_UUID%\"}
echo " $DELETION_UUID."
echo -n "Deleting user $DELETION_UUID..."
curl -X DELETE -f -s \
http://$HOST:12402/user/$DELETION_UUID > /dev/null
if [ $? -ne 0 ]; then
echo 'Exiting due to previous error'
exit 1
fi
echo ' Done.'