Skip to content

Commit

Permalink
!feature: Category management
Browse files Browse the repository at this point in the history
  • Loading branch information
whustedt committed Jun 9, 2024
1 parent 431f60f commit 0bd9e71
Show file tree
Hide file tree
Showing 27 changed files with 919 additions and 389 deletions.
13 changes: 0 additions & 13 deletions .codesandbox/tasks.json

This file was deleted.

40 changes: 27 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Calendarium

This is a simple Flask web application designed for tracking and managing event data, featuring a dynamic timeline view.
This is a Flask-based web application designed to manage events and visualize them on a dynamic timeline. This application supports integration with Grafana for visual analytics and the Giphy API for enhanced user interaction.

## Getting Started

Expand All @@ -12,7 +12,7 @@ To get the application running locally, you have several options:
docker-compose up --build
```

The app will be available at [http://localhost:5001/](http://localhost:5001/).
The app will be available at [http://127.0.0.1:5000/](http://127.0.0.1:5000/).

### Using a Development Container or Python Environment

Expand All @@ -24,7 +24,7 @@ flask run --debug

For production you would use Gunicorn as a WSGI server:
```bash
gunicorn -w 4 -b "localhost:5001" "app:create_app()"
gunicorn -w 4 -b "127.0.0.1:5000" "app:create_app()"
```

Ensure your environment has all the necessary dependencies installed as specified in the `requirements.txt` file.
Expand All @@ -42,7 +42,7 @@ GIPHY_API_TOKEN=<your_giphy_api_token>
To populate the application with sample data, run:

```bash
curl -X POST http://localhost:5001/batch-import -H "Content-Type: application/json" -d @testdata.json
curl -X POST http://127.0.0.1:5000/batch-import -H "Content-Type: application/json" -d @testdata.json
```

## API Endpoints
Expand Down Expand Up @@ -85,13 +85,27 @@ Below are the available API endpoints with their respective usage:
- **POST** `/batch-import`
- Imports a batch of entries from a JSON file.

- **Update Birthdays**
- **PUT** `/update-birthdays`
- Updates all birthday entries to the current year.
- **Update Serial Entries**
- **POST** `/update-serial-entries`
- Updates all entries linked to categories that are set to repeat annually, adjusting their dates to the current year.

- **Purge Old Entries**
- **POST** `/purge-old-entries`
- Deletes all entries where the date is in the past and the category is not 'birthday'.
- Deletes all entries where the date is in the past and the category is not marked as protected.

## Category Management

- **View and Manage Categories**
- **GET/POST** `/categories`
- Displays and allows management of categories including creation and update.

- **Update Category**
- **POST** `/categories/update/<int:id>`
- Allows updating details for a specific category by ID.

- **Delete Category**
- **POST** `/categories/delete/<int:id>`
- Deletes a category if it is not associated with any entries.

## Grafana Integration

Expand All @@ -101,7 +115,7 @@ This application supports integration with Grafana through a Simple JSON Datasou

- **Test Connection** (`GET /grafana/`)
- Confirms the data source connection is functional.

- **Search** (`POST /grafana/search`)
- Returns a list of categories that can be queried (e.g., 'cake', 'birthday').

Expand All @@ -122,7 +136,7 @@ This application supports integration with Grafana through a Simple JSON Datasou
Query Grafana for timeseries data in the 'cake' category using this `curl` command:

```bash
curl -X POST http://127.0.0.1:5000/grafana/query -H "Content-Type: application/json" -d '{"targets":[{"target": "cake", "type": "timeserie"}]}'
curl -X POST http://127.0.0.1:5000/grafana/query -H "Content-Type: application/json" -d '{"targets":[{"target": "Cake", "type": "timeserie"}]}'
```

This command will return timeseries data points for the 'cake' category if such data exists. Ensure the Grafana Simple JSON Datasource plugin is installed and properly configured to interact with these endpoints.
Expand All @@ -141,7 +155,7 @@ In environments where the backend server has internet access, the application ca

Example usage:
```bash
curl http://localhost:5001/search_gifs?q=cats
curl http://127.0.0.1:5000/search_gifs?q=cats
```
This implementation is found in the JavaScript file `search_gifs.js`.

Expand All @@ -155,7 +169,7 @@ For environments where the backend does not have direct internet access, we empl

Example usage:
```bash
curl http://localhost:5001/get-giphy-url?q=cats
curl http://127.0.0.1:5000/get-giphy-url?q=cats
```
This implementation is located in the JavaScript file `search_gifs_proxied.js`.

Expand All @@ -170,7 +184,7 @@ This project uses Flickity, which is licensed under the GPLv3.
## Icon Attribution

This project uses the icon "cracked glass" by Olena Panasovska from [Noun Project](https://thenounproject.com/icon/cracked-glass-3292568/) licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). To meet the attribution requirements, this link points directly to the icon's detail page. Please refer to the Noun Project's guidelines for detailed information on how to properly attribute the creator in different formats and mediums.

## Docker Image

Instead of Docker Hub, this project's Docker images are now built and pushed through GitHub Actions to the GitHub Container Registry.
5 changes: 3 additions & 2 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate, upgrade
from .config import Config
import os.path


db = SQLAlchemy()
migrate = Migrate()
Expand All @@ -21,6 +19,9 @@ def create_app(config_class=Config):
from .routes_grafana import init_grafana_routes
init_grafana_routes(app)

from .routes_categories import init_categories_routes
init_categories_routes(app)

with app.app_context():

if not app.config['TESTING']:
Expand Down
105 changes: 76 additions & 29 deletions app/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
from urllib.parse import urlparse
import requests
from werkzeug.utils import secure_filename
from colorsys import rgb_to_hls, hls_to_rgb
from .models import Entry, Category
from sqlalchemy.orm import joinedload


def handle_image_upload(entry_id, file, giphy_url, upload_folder, allowed_extensions):
"""Handle Giphy URL or file upload."""
Expand Down Expand Up @@ -67,41 +71,66 @@ def create_upload_folder(upload_folder):
if not path.exists(upload_folder):
makedirs(upload_folder, exist_ok=True)

def get_formatted_entries(entries):
"""Formats entries for display, including additional attributes."""
data = []
today = str(date.today())
index = next((i for i, entry in enumerate(entries) if entry.date >= today), len(entries))

for i, entry in enumerate(entries):
entry_data = {
'date': entry.date,
'date_formatted': format_date(parse_date(entry.date), 'd. MMMM', locale='de_DE'),
'category': entry.category,
'title': entry.title,
'description': entry.description,
'url': entry.url,
'last_updated_by': entry.last_updated_by,
'image_url': url_for('uploaded_file', filename=entry.image_filename) if entry.image_filename else None,
'image_url_external': url_for('uploaded_file', filename=entry.image_filename, _external=True) if entry.image_filename else None,
'index': i - index,
'isToday': entry.date == today,
'cancelled': entry.cancelled
}
data.append(entry_data)

return data

def create_zip(entries, upload_folder):
def get_data(db):
"""Returns formatted entries and categories data with complete category details for each entry."""
# Preload categories to avoid N+1 query issues
entries = db.session.query(Entry).options(joinedload(Entry.category)).order_by(Entry.date).all()
categories = db.session.query(Category).all()

# Determine the first upcoming or current entry
today_str = str(date.today())
index = next((i for i, entry in enumerate(entries) if entry.date >= today_str), len(entries))

formatted_entries = [{
"id": entry.id,
"date": entry.date,
"date_formatted": format_date(parse_date(entry.date), 'd. MMMM', locale='de_DE'),
"title": entry.title,
"description": entry.description,
"category": {
"id": entry.category.id,
"name": entry.category.name,
"symbol": entry.category.symbol,
"color_hex": entry.category.color_hex,
"color_hex_variation": adjust_lightness(entry.category.color_hex), # Assumes a function to adjust color brightness
"repeat_annually": entry.category.repeat_annually,
"display_celebration": entry.category.display_celebration,
"is_protected": entry.category.is_protected,
"last_updated_by": entry.category.last_updated_by
},
"url": entry.url,
"image_url": url_for('uploaded_file', filename=entry.image_filename) if entry.image_filename else None,
"image_url_external": url_for('uploaded_file', filename=entry.image_filename, _external=True) if entry.image_filename else None,
"index": i - index,
"is_today": entry.date == today_str,
"cancelled": entry.cancelled,
"last_updated_by": entry.last_updated_by
} for i, entry in enumerate(entries)]

formatted_categories = [{
"id": category.id,
"name": category.name,
"symbol": category.symbol,
"color_hex": category.color_hex,
"color_hex_variation": adjust_lightness(category.color_hex),
"repeat_annually": category.repeat_annually,
"display_celebration": category.display_celebration,
"is_protected": category.is_protected,
"last_updated_by": category.last_updated_by
} for category in categories]

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

def create_zip(data, upload_folder):
"""Creates a zip file containing entries data and associated images."""
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
# Add entries.json file
entries_json = jsonify(entries).get_data(as_text=True)
zip_file.writestr('entries.json', entries_json)
entries_json = jsonify(data).get_data(as_text=True)
zip_file.writestr('data.json', entries_json)

# Add image files
for entry in entries:
for entry in data.get('entries'):
if entry['image_url']:
image_filename = entry['image_url'].split('/')[-1]
image_path = path.join(upload_folder, image_filename)
Expand All @@ -110,3 +139,21 @@ def create_zip(entries, upload_folder):

zip_buffer.seek(0)
return zip_buffer

def hex_to_rgb(value):
"""Convert hex to RGB"""
value = value.lstrip('#')
lv = len(value)
return tuple(int(value[i:i + lv // 3], 16) for i in range(0, lv, lv // 3))

def rgb_to_hex(rgb):
"""Convert RGB to hex"""
return '#%02x%02x%02x' % rgb

def adjust_lightness(color, adjustment_factor=0.9):
"""Adjust the lightness of the color"""
r, g, b = hex_to_rgb(color)
h, l, s = rgb_to_hls(r/255., g/255., b/255.)
l = max(0, min(1, l * adjustment_factor)) # Ensure lightness stays within 0 to 1
r, g, b = hls_to_rgb(h, l, s)
return rgb_to_hex((int(r * 255), int(g * 255), int(b * 255)))
18 changes: 13 additions & 5 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
from . import db

class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
symbol = db.Column(db.String(10), nullable=False)
color_hex = db.Column(db.String(10), nullable=False)
repeat_annually = db.Column(db.Boolean, default=False, nullable=False)
display_celebration = db.Column(db.Boolean, default=False, nullable=False)
is_protected = db.Column(db.Boolean, default=False, nullable=False)
last_updated_by = db.Column(db.String(130), nullable=True)

class Entry(db.Model):
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.String(100), nullable=False)
category = db.Column(db.String(100), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('category.id'), nullable=False)
category = db.relationship('Category', backref=db.backref('entries', lazy=True))
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.String(1000), nullable=True)
image_filename = db.Column(db.String(100), nullable=True)
url = db.Column(db.String(1000), nullable=True)
cancelled = db.Column(db.Boolean, nullable=False, default=False)
last_updated_by = db.Column(db.String(130), nullable=True)

# Define category choices
CATEGORIES = ['cake', 'birthday', 'release', 'custom']
last_updated_by = db.Column(db.String(130), nullable=True)
Loading

0 comments on commit 0bd9e71

Please sign in to comment.