Skip to content

Commit

Permalink
feature: Added category filtering and database export
Browse files Browse the repository at this point in the history
  • Loading branch information
whustedt committed Jun 12, 2024
1 parent aaf6509 commit da3cee4
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 43 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Alternatively, you can set up a development container or any other Python enviro
flask run --debug
```

For production you would use Gunicorn as a WSGI server:
For production, you would use Gunicorn as a WSGI server:
```bash
gunicorn -w 4 -b "127.0.0.1:5000" "app:create_app()"
```
Expand Down Expand Up @@ -54,8 +54,8 @@ Below are the available API endpoints with their respective usage:
- Returns the main page of the application.

- **Timeline**
- **GET** `/timeline?timeline-height=<height>&font-family=<font>&font-scale=<scale>`
- Displays a timeline of all entries. Allows optional `timeline-height`, `font-family`, and `font-scale` query parameters to adjust the height of the timeline, set the font, and apply a scale factor to the font size respectively (e.g., `timeline-height=100%`, `font-family=Arial`, `font-scale=1.5`). This view uses the Flickity library.
- **GET** `/timeline?timeline-height=<height>&font-family=<font>&font-scale=<scale>&categories=<category_names>`
- Displays a timeline of all entries. Allows optional `timeline-height`, `font-family`, `font-scale`, and `categories` query parameters to adjust the height of the timeline, set the font, apply a scale factor to the font size, and filter entries by specified categories respectively (e.g., `categories=Cake,Birthday`).

- **Create Entry**
- **POST** `/create`
Expand Down
27 changes: 21 additions & 6 deletions app/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from os import path, makedirs
import zipfile
from io import BytesIO
from urllib.parse import urlparse
from urllib.parse import urlparse, unquote_plus
import requests
from werkzeug.utils import secure_filename
from colorsys import rgb_to_hls, hls_to_rgb
Expand Down Expand Up @@ -71,10 +71,19 @@ def create_upload_folder(upload_folder):
if not path.exists(upload_folder):
makedirs(upload_folder, exist_ok=True)

def get_data(db):
def get_data(db, category_filter=None):
"""Returns formatted entries and categories data with complete category details for each entry."""
# Parse the category_filter if provided
filter_categories = category_filter.split(',') if category_filter else None

# Preload categories to avoid N+1 query issues
entries = db.session.query(Entry).options(joinedload(Entry.category)).order_by(Entry.date).all()
query = db.session.query(Entry).options(joinedload(Entry.category)).order_by(Entry.date)

if filter_categories:
# Filter entries based on the category names
query = query.join(Category).filter(Category.name.in_(filter_categories))

entries = query.all()
categories = db.session.query(Category).all()

# Determine the first upcoming or current entry
Expand Down Expand Up @@ -121,8 +130,8 @@ def get_data(db):

return {"entries": formatted_entries, "categories": formatted_categories}

def create_zip(data, upload_folder):
"""Creates a zip file containing entries data and associated images."""
def create_zip(data, upload_folder, db_uri):
"""Creates a zip file containing entries data, associated images, and the database file."""
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
# Add entries.json file
Expand All @@ -136,7 +145,13 @@ def create_zip(data, upload_folder):
image_path = path.join(upload_folder, image_filename)
if path.exists(image_path):
zip_file.write(image_path, arcname=image_filename)


# Add the database file if the URI points to a SQLite database
if db_uri.startswith("sqlite:///"):
db_path = unquote_plus(db_uri[10:]) # Strip 'sqlite:///' and decode URI encoding
if path.exists(db_path):
zip_file.write(db_path, arcname='data.db')

zip_buffer.seek(0)
return zip_buffer

Expand Down
11 changes: 7 additions & 4 deletions app/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,17 +221,20 @@ def toggle_cancelled(id):
except Exception as e:
current_app.logger.error(f"Error toggling the cancelled state of the entry: {e}")
return jsonify({"error": "Failed to update entry"}), 500

@app.route('/timeline', methods=['GET'])
def timeline():
"""Generate a timeline view of entries, calculating positions based on dates."""
timeline_height = request.args.get('timeline-height', default='calc(50vh - 20px)')[:25]
font_family = request.args.get('font-family', default='sans-serif')[:35]
font_scale = request.args.get('font-scale', default='1')[:5]
data = get_data(db)
category_filter = request.args.get('categories') # Get category filter from query parameters

data = get_data(db, category_filter)
display_celebration = any(entry.get('is_today') and entry.get('category').get('display_celebration') for entry in data.get('entries'))

return make_response(render_template('timeline/timeline.html', entries=data.get('entries'), categories=data.get('categories'), display_celebration=display_celebration, timeline_height=timeline_height, font_family=font_family, font_scale=font_scale))

@app.route('/api/data', methods=['GET'])
def api_data():
"""Return a JSON response with data for all data, including image URLs."""
Expand Down Expand Up @@ -331,7 +334,7 @@ def batch_import():
def export_data():
"""Export all data and associated images as a zip file."""
data = get_data(db)
zip_buffer = create_zip(data, app.config['UPLOAD_FOLDER'])
zip_buffer = create_zip(data, app.config['UPLOAD_FOLDER'], app.config['SQLALCHEMY_DATABASE_URI'])

response = make_response(send_file(zip_buffer, mimetype='application/zip', as_attachment=True, download_name='data_export.zip'))
response.headers['Content-Disposition'] = 'attachment; filename=data_export.zip'
Expand Down
62 changes: 32 additions & 30 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from io import BytesIO
from os import path
from urllib.parse import unquote_plus
import zipfile
from flask.testing import FlaskClient
from unittest import mock
from datetime import datetime
Expand Down Expand Up @@ -100,37 +104,35 @@ def test_get_data(test_client: FlaskClient, init_database: None):
assert entry['title'] == "John's Birthday"
assert entry['category']['name'] == "Birthday"

def test_create_zip(test_client: FlaskClient, init_database: None):
# Given: A list of entries and mocked file system operations
# When: create_zip is called
# Then: It should return a buffer with the ZIP file content

def test_create_zip(test_client, init_database):
data = {
'categories': [
{
'color_hex': '#FF8A65',
'name': 'Cake',
'symbol': '🍰'
}
],
'entries': [
{
'category': {
'color_hex': '#FF8A65',
'name': 'Cake',
'symbol': '🍰'
},
'date': '2024-06-01',
'image_url': '/uploads/test.jpg',
'title': 'Old',
}
]
'categories': [{'color_hex': '#FF8A65', 'name': 'Cake', 'symbol': '🍰'}],
'entries': [{
'category': {'color_hex': '#FF8A65', 'name': 'Cake', 'symbol': '🍰'},
'date': '2024-06-01',
'image_url': '/uploads/test.jpg',
'title': 'Old',
}]
}

# Assuming the path '/uploads/test.jpg' needs to be converted to an absolute path
# This would typically depend on how your application resolves paths
upload_folder = '/full/path/to/uploads'
expected_image_path = path.join(upload_folder, 'test.jpg')
db_uri = 'sqlite:////app/data/data.db'
expected_db_path = unquote_plus(db_uri[10:])

# Mock the os.path.exists to always return True
with mock.patch('os.path.exists', return_value=True):
with mock.patch('builtins.open', mock.mock_open(read_data='test content')):
with mock.patch('app.helpers.path.join', return_value='uploads/test.jpg'):
with mock.patch('zipfile.ZipFile.write') as mock_write:
zip_buffer = create_zip(data, 'uploads')
assert zip_buffer is not None
mock_write.assert_any_call('uploads/test.jpg', arcname='test.jpg')
with mock.patch('builtins.open', mock.mock_open(read_data='test content'), create=True):
with mock.patch('zipfile.ZipFile') as mock_zip:
zip_file_instance = mock_zip.return_value.__enter__.return_value
zip_file_instance.write = mock.Mock()

# Call function
create_zip(data, upload_folder, db_uri)

# Check if the image and database file were correctly written to the zip
zip_file_instance.write.assert_any_call(expected_image_path, arcname='test.jpg')
zip_file_instance.write.assert_any_call(expected_db_path, arcname='data.db')

46 changes: 46 additions & 0 deletions tests/test_routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from app.helpers import get_data
from app.models import Entry, Category
from app import db
from datetime import datetime, date, timedelta
Expand Down Expand Up @@ -65,6 +66,51 @@ def test_timeline_view(test_client):
assert response.status_code == 200
# Additional assertions can check specific content or data structures1

def test_timeline_view_with_category_filter(test_client, init_database):
"""
GIVEN a Flask application configured for testing
WHEN the '/timeline' page is requested (GET) with a category filter
THEN check the response is valid and only entries from specified categories are returned
"""
# Add another category and entry to test filtering
category = db.session.query(Category).filter_by(name="Release").first()
if not category:
category = Category(name="Release", symbol="🚀", color_hex="#FF6347", repeat_annually=False, display_celebration=False, is_protected=False)
db.session.add(category)
db.session.commit()

entry = Entry(date="2023-06-01", category_id=category.id, title="Release Update", description="Major software release.")
db.session.add(entry)
db.session.commit()

# Request timeline with category filter
response = test_client.get('/timeline?categories=Release')
assert response.status_code == 200
assert b"Release Update" in response.data
assert b"Birthday" not in response.data # Entry from another category should not appear

def test_timeline_view_without_category_filter(test_client, init_database):
"""
GIVEN a Flask application configured for testing
WHEN the '/timeline' page is requested (GET) without a category filter
THEN check the response is valid and all entries are returned
"""
# Add another category and entry to test filtering
category = db.session.query(Category).filter_by(name="Release").first()
if not category:
category = Category(name="Release", symbol="🚀", color_hex="#FF6347", repeat_annually=False, display_celebration=False, is_protected=False)
db.session.add(category)
db.session.commit()

entry = Entry(date="2023-06-01", category_id=category.id, title="Release Update", description="Major software release.")
db.session.add(entry)
db.session.commit()

response = test_client.get('/timeline')
assert response.status_code == 200
assert b"Birthday" in response.data # Ensure entries from all categories are displayed
assert b"Release Update" in response.data # Assuming this entry was added in the previous test

def test_create_entry_with_invalid_data(test_client):
"""
GIVEN a Flask application
Expand Down

0 comments on commit da3cee4

Please sign in to comment.