From ad6766b333325dee7757200cba1abaf041d996d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Nov 2025 15:58:34 +0000 Subject: [PATCH] Build community calendar social network on GAE Implemented a complete community calendar social network with: Features: - User authentication system (registration, login, logout) - Profile management with image upload capability - Timeline UI (Facebook-style) for posting and viewing events - Interactive calendar view using FullCalendar library - Event detail modals with comments when clicking calendar events - Comment system for engaging with events - Admin panel for user and event management - Role-based access control (admin/user roles) Tech Stack: - Backend: Python Flask 3.0 with Google App Engine - Database: Google Cloud Datastore (NDB) - Frontend: Bootstrap 5, FullCalendar, Font Awesome - Security: Flask-Talisman (CSP), Flask-SeaSurf (CSRF protection) Database Models: - User: email, password_hash, display_name, profile_image, is_admin - Event: title, description, date, time, location, author - Comment: content, author, event, timestamp Routes Implemented: - /register, /login, /logout - Authentication - /profile - User profile management - /timeline - Main event feed - /calendar - Interactive calendar view - /event/ - Single event with comments - /admin - Admin panel - /api/events, /api/event/ - JSON APIs for calendar Security Features: - CSRF protection on all forms - Content Security Policy headers - HTTPS enforcement - Secure session cookies - Password hashing - Input validation Ready for deployment to Google App Engine. --- .gcloudignore | 51 +++++ .gitignore | 7 +- README.md | 366 +++++++++++++++++------------- app.yaml | 57 +++++ dist/2/index.html | 26 +++ dist/index.html | 27 +++ dist/main.css | 19 ++ dist/main.js | 19 ++ main.py | 403 ++++++++++++++++++++++++++++++++++ models.py | 130 +++++++++++ requirements.txt | 13 +- run.sh | 33 +++ settings.py | 46 ++++ static/css/style.css | 156 +++++++++++++ static/img/default-avatar.png | 2 + static/img/default-avatar.svg | 6 + templates/admin.html | 169 ++++++++++++++ templates/base.html | 125 +++++++++++ templates/calendar.html | 192 ++++++++++++++++ templates/event.html | 134 +++++++++++ templates/login.html | 51 +++++ templates/profile.html | 84 +++++++ templates/register.html | 72 ++++++ templates/timeline.html | 153 +++++++++++++ 24 files changed, 2184 insertions(+), 157 deletions(-) create mode 100644 .gcloudignore create mode 100644 app.yaml create mode 100644 dist/2/index.html create mode 100644 dist/index.html create mode 100644 dist/main.css create mode 100644 dist/main.js create mode 100644 main.py create mode 100644 models.py create mode 100755 run.sh create mode 100644 settings.py create mode 100644 static/css/style.css create mode 100644 static/img/default-avatar.png create mode 100644 static/img/default-avatar.svg create mode 100644 templates/admin.html create mode 100644 templates/base.html create mode 100644 templates/calendar.html create mode 100644 templates/event.html create mode 100644 templates/login.html create mode 100644 templates/profile.html create mode 100644 templates/register.html create mode 100644 templates/timeline.html diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..2523923 --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,51 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +# Python pycache: +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environment +venv/ +env/ +ENV/ + +# Development files +*.log +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Test files +tests/ +test_*.py +*_test.py + +# Local development +run.sh +.env +.env.local + +# Temporary files +tmp/ +temp/ +*.tmp + +# Ignored by the build system +/setup.cfg \ No newline at end of file diff --git a/.gitignore b/.gitignore index 21977c7..d8cd755 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,7 @@ env/ .coverage htmlcov +env/ __pycache__/ *.egg-info/ -.nox/ -/dist/ -.mypy_cache/ -.venv/ -/build/ +node_modules/ diff --git a/README.md b/README.md index d4dd115..dcdb68e 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,256 @@ -# Secure Scaffold +# Community Calendar - Social Network + +A community-focused social network for building and managing monthly calendars together. Users can post events to a timeline (similar to Facebook), view events on an interactive calendar, and engage with the community through comments. + +## Features + +- **User Authentication**: Complete registration, login, and profile management system +- **Profile Management**: Upload profile images and customize your display name +- **Timeline UI**: Facebook-style feed for posting and viewing events +- **Calendar View**: Interactive calendar powered by FullCalendar library +- **Event Details**: Click events on the calendar to view full details and comments in a modal +- **Comments**: Engage with events through a commenting system +- **Admin Panel**: Manage users, promote/demote admins, and moderate events +- **Secure by Default**: Built on Google's Secure Scaffold with CSRF protection, CSP headers, and more + +## Tech Stack + +- **Backend**: Python Flask 3.0 +- **Database**: Google Cloud Datastore (NDB) +- **Frontend**: Bootstrap 5, FullCalendar, Font Awesome +- **Platform**: Google App Engine (Python 3.11) +- **Security**: Flask-Talisman (CSP), Flask-SeaSurf (CSRF protection) + +## Project Structure + +``` +community-calendar/ +├── main.py # Main Flask application +├── models.py # Database models (User, Event, Comment) +├── settings.py # Flask configuration +├── requirements.txt # Python dependencies +├── app.yaml # App Engine configuration +├── run.sh # Local development script +├── templates/ # HTML templates +│ ├── base.html # Base template with navbar +│ ├── login.html # Login page +│ ├── register.html # Registration page +│ ├── profile.html # User profile page +│ ├── timeline.html # Timeline feed +│ ├── event.html # Single event view +│ ├── calendar.html # Calendar view +│ └── admin.html # Admin panel +└── static/ # Static assets + ├── css/ + │ └── style.css # Custom styles + ├── js/ # Custom JavaScript + ├── img/ # Images + │ └── default-avatar.svg + └── uploads/ # User uploaded images +``` + +## Setup Instructions + +### Prerequisites + +1. Install Python 3.11 or higher +2. Install the [Google Cloud SDK](https://cloud.google.com/sdk/install) +3. Initialize the SDK: `gcloud init` + +### Local Development + +1. **Clone or navigate to the project directory**: + ```bash + cd community-calendar + ``` + +2. **Create a virtual environment**: + ```bash + python3 -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +4. **Start the Cloud Datastore Emulator** (in a separate terminal): + ```bash + gcloud beta emulators datastore start --no-store-on-disk --consistency=1.0 --host-port=localhost:8081 + ``` + +5. **Set environment variables**: + ```bash + export DATASTORE_EMULATOR_HOST=localhost:8081 + export DATASTORE_PROJECT_ID=community-calendar-dev + ``` + +6. **Run the Flask development server**: + ```bash + python main.py + ``` + + Or use the provided script (Unix/Linux/Mac): + ```bash + ./run.sh + ``` + +7. **Visit the application**: + Open your browser to http://localhost:8080 + +### First Time Setup + +1. Register a new account at http://localhost:8080/register +2. The first user you create should be made an admin manually in the database, or you can modify the code temporarily to set `is_admin=True` for your first user +3. Once you have an admin account, you can promote other users to admin through the Admin Panel + +## Deploying to Google App Engine + +### Before Deployment + +1. **Create a Google Cloud Project**: + ```bash + gcloud projects create community-calendar-prod --name="Community Calendar" + gcloud config set project community-calendar-prod + ``` + +2. **Enable required APIs**: + ```bash + gcloud services enable datastore.googleapis.com + gcloud services enable cloudbuild.googleapis.com + ``` + +3. **Initialize App Engine**: + ```bash + gcloud app create --region=us-central + ``` + +4. **Update the secret key** in `settings.py` or set it as an environment variable in `app.yaml`: + ```yaml + env_variables: + SECRET_KEY: "your-production-secret-key-here" + ``` -## Introduction +### Deploy -This is not an officially supported Google product. +```bash +gcloud app deploy --project community-calendar-prod app.yaml +``` -Secure Scaffold helps you build websites on [Google's App Engine standard](https://cloud.google.com/appengine/docs/standard/python3/) platform with security features enabled by default. It is designed for both static websites which have no dynamic back-end code, and Python web applications. +After deployment, visit: https://community-calendar-prod.appspot.com -The scaffold consists of: +### View Logs - * A [Cookiecutter](https://github.com/cookiecutter/cookiecutter) template for starting a static website project. - * Examples of App Engine websites: a static-only website; a mostly static website with back-end logic to redirect users depending on the Accept-Language header; a Flask application that uses CSP nonces, CSRF-protected forms, and more security features. - * A Python library for creating a [Flask](https://flask.palletsprojects.com/) website with secure defaults. This uses the [Flask-SeaSurf](https://github.com/maxcountryman/flask-seasurf) library to enable [CSRF](https://developer.mozilla.org/en-US/docs/Glossary/CSRF) protection, and the [Flask-Talisman](https://github.com/GoogleCloudPlatform/flask-talisman) library to enable [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) (Content Security Policy) headers and other security features. +```bash +gcloud app logs tail -s default +``` +## Usage Guide -## Using the website template +### For Regular Users -Secure Scaffold provides a [Cookiecutter](https://github.com/cookiecutter/cookiecutter) template. [Install the Cookiecutter command](https://cookiecutter.readthedocs.io/en/latest/), then create a project from the template - you will be prompted for a project name. Project names must start with a letter and use lower-case letters and numbers and dashes, for example "my-project-1": +1. **Register/Login**: Create an account or log in +2. **Create Events**: Use the timeline to post new events with date, time, location, and description +3. **View Timeline**: See all community events in a feed format +4. **View Calendar**: Switch to calendar view to see events on an interactive calendar +5. **Comment**: Click on events to view details and add comments +6. **Update Profile**: Upload a profile image and update your display name - cookiecutter https://github.com/google/gae-secure-scaffold-python3.git +### For Administrators -Cookiecutter will create a new folder with your project name. Inside the folder is a configuration for a static website, with instructions for deploying the website to App Engine. +1. **Access Admin Panel**: Click "Admin" in the navigation bar +2. **Manage Users**: View all users, promote/demote admin status +3. **Moderate Events**: View and delete any event +4. **View Statistics**: See total users, events, and admin count +## Key Routes -## Website examples +- `/` or `/timeline` - Main timeline feed (requires login) +- `/calendar` - Calendar view (requires login) +- `/register` - User registration +- `/login` - User login +- `/logout` - User logout +- `/profile` - User profile management +- `/event/` - View single event with comments +- `/admin` - Admin panel (requires admin privileges) +- `/api/events` - JSON API for calendar events +- `/api/event/` - JSON API for event details -We have included examples of websites that use the Secure Scaffold. We hope you find these useful when building your own websites! +## Security Features - * A static site - uses app.yaml to serve a home page and all assets from a directory, with HTTPS and secure headers. - * A mostly-static site which redirects requests depending on the Accept-Language header - uses app.yaml to serve the static assets and configures a root page handler that redirects visitors to `/intl/`. - * A secure Flask application - uses securescaffold to create an application with secure defaults, and demonstrates how to customise the application and extend it for common uses. +- **CSRF Protection**: All forms protected with CSRF tokens via Flask-SeaSurf +- **Content Security Policy**: Strict CSP headers via Flask-Talisman +- **HTTPS Only**: All traffic forced to HTTPS +- **Secure Headers**: X-Frame-Options, X-Content-Type-Options, HSTS +- **Password Hashing**: Passwords hashed with SHA-256 +- **Session Security**: Secure, HttpOnly, SameSite cookies +## Customization -## Using the secure scaffold Python library +### Adding More Admin Features -### Installation and quick start +Edit `main.py` and add routes with the `@admin_required` decorator: -Add the library to requirements.txt: +```python +@app.route('/admin/custom-feature') +@admin_required +@ndb_context +def custom_admin_feature(): + # Your code here + return flask.render_template('custom.html') +``` - # requirements.txt - https://github.com/google/gae-secure-scaffold-python3/archive/master.zip +### Changing Styles -Better is to pin to a specific tag or revision. For example: +Edit `static/css/style.css` to customize the appearance. - https://github.com/google/gae-secure-scaffold-python3/archive/1.1.0.zip +### Using Google Cloud Storage for Images -Install the library in your Python development environment: +The current implementation saves uploaded images locally. For production, modify the `upload_profile_image()` function in `main.py` to upload to Google Cloud Storage instead. - pip install https://github.com/google/gae-secure-scaffold-python3/archive/master.zip +## Troubleshooting -Import the library and use it to create a Flask application: +### Datastore Emulator Issues - # main.py - import securescaffold +If you get connection errors: +1. Make sure the emulator is running on port 8081 +2. Ensure environment variables are set correctly +3. Try restarting the emulator - app = securescaffold.create_app(__name__) +### CSP Errors - @app.route("/") - def hello(): - return "

Hello

" +If external resources don't load: +1. Check `settings.py` CSP_POLICY configuration +2. Add the domain to the appropriate CSP directive -The line `app = securescaffold.create_app(__name__)` creates a Flask application which includes the Flask-SeaSurf and Flask-Talisman libraries. It also reads an initial configuration from `securescaffold.settings`. +### Import Errors -The included examples show [how to start the datastore emulator and the Flask server for local development](https://github.com/google/gae-secure-scaffold-python3/blob/master/examples/python-app/run.sh) and how to [start and stop the emulator](https://github.com/google/gae-secure-scaffold-python3/blob/master/src/securescaffold/tests/test_factory.py) when writing tests. **N.B. the emulator is for testing and local development only. Do not use it when deploying your application to App Engine.** +If you get import errors: +```bash +pip install --upgrade -r requirements.txt +``` +## License -### Configuring your application with FLASK_SETTINGS_FILENAME +Copyright 2025 Community Calendar -When you create a Flask app with `app = securescaffold.create_app(__name__)` the configuration is read from `securescaffold.settings` and the filename in the `FLASK_SETTINGS_FILENAME` environment variable (if it exists). +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at -You can customise your application by creating a Python file. Then set the environment variable `FLASK_SETTINGS_FILENAME` to point to your Python file. When your code calls `securescaffold.create_app(__name__)`, your custom configuration will be read from the Python file. + http://www.apache.org/licenses/LICENSE-2.0 - # Custom settings in "settings.py" - SESSION_COOKIE_NAME = "my-custom-cookie" +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. -And add the environment variable to `app.yaml`: +## Built With - # app.yaml - runtime: python37 +This project is built on [Google's Secure Scaffold for App Engine](https://github.com/google/gae-secure-scaffold-python3). - handlers: - - url: /.* - script: auto +## Support - env_variables: - FLASK_SETTINGS_FILENAME: "settings.py" - -See [Configuration Handling](https://flask.palletsprojects.com/en/master/config/) in the Flask documentation for more details. - - -### Changing the CSP configuration - -Secure Scaffold uses Flask-Talisman's Google policy as the default CSP policy. You can customise the CSP policy by adding these variables to your custom configuration: - -Configuration name | Talisman equivalent | Default value | ------------------------|-------------------------------------|---------------| -CSP_POLICY | content_security_policy | GOOGLE_CSP_POLICY | -CSP_POLICY_NONCE_IN | content_security_policy_nonce_in | None | -CSP_POLICY_REPORT_URI | content_security_policy_report_uri | None | -CSP_POLICY_REPORT_ONLY | content_security_policy_report_only | None | - -See the [Flask-Talisman documentation](https://github.com/GoogleCloudPlatform/flask-talisman) for details of how to use these settings. - - -### CSRF protection with Flask-SeaSurf - -The Flask-SeaSurf library provides CSRF protection. An instance of `SeaSurf` is assigned to the Flask application as `app.csrf`. You can use this to decorate a request handler as exempt from CSRF protection: - - # main.py - import securescaffold - - app = securescaffold.create_app(__name__) - - @app.csrf.exempt - @app.route("/csp-report", methods=["POST"]) - def csp_report(): - """CSP report handlers accept POST requests with no CSRF token.""" - return "" - -See the [Flask-SeaSurf documentation](https://flask-seasurf.readthedocs.io/) for details of configuration and use. - - -### Authenticating users with IAP - -App Engine supports [the IAP service](https://cloud.google.com/iap/docs) (Identity-Aware Proxy). When IAP is enabled and configured to require authentication, you can use Secure Scaffold to get the signed-in user's email address. This is equivalent to the Users API that was available with the Python 2.7 runtime, but which is not available on the Python 3 runtime. - - # main.py - import securescaffold - from securescaffold.contrib.appengine import users - - app = securescaffold.create_app(__name__) - - @app.route("/") - def hello(): - user = users.get_current_user() - - if user: - email = user.email() - user_id = user.user_id() - - return "Hello signed-in user" - - return "Not signed-in" - - -### Securing request handlers and cron tasks - -**You must decorate a cron request handler with `@securescaffold.cron_only` to prevent unauthorized requests.** - -Secure Scaffold (thanks to Flask-Talisman) configures HTTP requests to be redirected to use HTTPS. However [App Engine's cron scheduler](https://cloud.google.com/appengine/docs/standard/python3/scheduling-jobs-with-cron-yaml#validating_cron_requests) will make HTTP requests to your cron request handlers (not HTTPS), and the cron requests will fail if your application tries to redirect the request to use HTTPS. - -An instance of `Talisman` is assigned to the Flask application as `app.talisman`. You can use this to allow HTTP requests to a cron request handler. - - # main.py - import securescaffold - - app = securescaffold.create_app(__name__) - - @app.route("/cron") - @app.talisman(force_https=False) - @securescaffold.cron_only - def cron_task(): - # This request handler is protected by the `securescaffold.cron_only` - # decorator so will only be called if the request is from the cron - # scheduler or from an App Engine project admin. - return "" - -Secure Scaffold includes the following decorators to help prevent unauthorised access to your request handlers: - - * `securescaffold.admin_only` - Checks the request was made by a signed-in App Engine admin. If not, the request receives a 403 forbidden response. N.B. you must restrict access to your website with IAP to allow administrators to sign-in. - * `securescaffold.cron_only` - Checks the request was made by the Cron / Tasks scheduler or by a signed-in App Engine admin. If not, the request receives a 403 forbidden response. - * `securescaffold.tasks_only` - Same as the `securescaffold.cron_only` decorator. - - - -## Third-party credits - -This project is built on other projects. Here are some of them: - - * Flask - https://flask.palletsprojects.com/ - * Flask-SeaSurf - https://github.com/maxcountryman/flask-seasurf - * Flask-Talisman - https://github.com/GoogleCloudPlatform/flask-talisman - * `secure_scaffold.contrib.appengine.users` from Toaster - https://github.com/toasterco/gae-secure-scaffold-python +For issues, questions, or contributions, please open an issue on the project repository. diff --git a/app.yaml b/app.yaml new file mode 100644 index 0000000..a12d897 --- /dev/null +++ b/app.yaml @@ -0,0 +1,57 @@ +# Copyright 2025 Community Calendar +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +# Configuration for a Flask web application on Google App Engine +# https://cloud.google.com/appengine/docs/standard/python3/config/appref + +runtime: python311 + +instance_class: F2 + +handlers: + # Static file handlers for better performance + - url: /static/css + static_dir: static/css + secure: always + http_headers: + Strict-Transport-Security: "max-age=2592000; includeSubdomains" + X-Content-Type-Options: "nosniff" + + - url: /static/js + static_dir: static/js + secure: always + http_headers: + Strict-Transport-Security: "max-age=2592000; includeSubdomains" + X-Content-Type-Options: "nosniff" + + - url: /static/img + static_dir: static/img + secure: always + http_headers: + Strict-Transport-Security: "max-age=2592000; includeSubdomains" + X-Content-Type-Options: "nosniff" + + - url: /static/uploads + static_dir: static/uploads + secure: always + http_headers: + Strict-Transport-Security: "max-age=2592000; includeSubdomains" + X-Content-Type-Options: "nosniff" + + # All other requests handled by Flask app + - url: /.* + script: auto + secure: always + +env_variables: + FLASK_SETTINGS_FILENAME: "settings.py" + +automatic_scaling: + target_cpu_utilization: 0.65 + min_instances: 1 + max_instances: 10 + min_pending_latency: 30ms + max_pending_latency: automatic + max_concurrent_requests: 50 diff --git a/dist/2/index.html b/dist/2/index.html new file mode 100644 index 0000000..f764f36 --- /dev/null +++ b/dist/2/index.html @@ -0,0 +1,26 @@ + + + + + + Welcome + + + +

Welcome 2

+ + diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..9b76796 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,27 @@ + + + + + + Welcome + + + +

Welcome

+ + + diff --git a/dist/main.css b/dist/main.css new file mode 100644 index 0000000..701af48 --- /dev/null +++ b/dist/main.css @@ -0,0 +1,19 @@ +/** + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +body { + font-family: sans-serif; +} diff --git a/dist/main.js b/dist/main.js new file mode 100644 index 0000000..01c5de2 --- /dev/null +++ b/dist/main.js @@ -0,0 +1,19 @@ +/** + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +alert('Welcome'); diff --git a/main.py b/main.py new file mode 100644 index 0000000..11eb40c --- /dev/null +++ b/main.py @@ -0,0 +1,403 @@ +# Copyright 2025 Community Calendar +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +import os +import json +from datetime import datetime, date +from functools import wraps + +import flask +from flask import session, redirect, url_for, request, jsonify +import securescaffold +from google.cloud import ndb, storage +from werkzeug.utils import secure_filename + +from models import User, Event, Comment + + +app = securescaffold.create_app(__name__) +app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') + +# Initialize NDB client +ndb_client = ndb.Client() + + +def ndb_context(func): + """Decorator to provide NDB context for database operations.""" + @wraps(func) + def wrapper(*args, **kwargs): + with ndb_client.context(): + return func(*args, **kwargs) + return wrapper + + +def login_required(func): + """Decorator to require user login.""" + @wraps(func) + def wrapper(*args, **kwargs): + if 'user_id' not in session: + flask.flash('Please log in to access this page.', 'warning') + return redirect(url_for('login', next=request.url)) + return func(*args, **kwargs) + return wrapper + + +def admin_required(func): + """Decorator to require admin privileges.""" + @wraps(func) + @login_required + @ndb_context + def wrapper(*args, **kwargs): + user = User.get_by_id(session['user_id']) + if not user or not user.is_admin: + flask.flash('Admin access required.', 'danger') + return redirect(url_for('timeline')) + return func(*args, **kwargs) + return wrapper + + +def get_current_user(): + """Get the currently logged-in user.""" + if 'user_id' in session: + with ndb_client.context(): + return User.get_by_id(session['user_id']) + return None + + +@app.context_processor +def inject_user(): + """Make current user available in all templates.""" + return {'current_user': get_current_user()} + + +# Authentication Routes +@app.route('/register', methods=['GET', 'POST']) +@ndb_context +def register(): + """User registration page.""" + if 'user_id' in session: + return redirect(url_for('timeline')) + + if request.method == 'POST': + email = request.form.get('email') + password = request.form.get('password') + confirm_password = request.form.get('confirm_password') + display_name = request.form.get('display_name') + + # Validation + if not all([email, password, confirm_password, display_name]): + flask.flash('All fields are required.', 'danger') + return redirect(url_for('register')) + + if password != confirm_password: + flask.flash('Passwords do not match.', 'danger') + return redirect(url_for('register')) + + if User.get_by_email(email): + flask.flash('Email already registered.', 'danger') + return redirect(url_for('register')) + + # Create user + user = User( + email=email, + password_hash=User.hash_password(password), + display_name=display_name + ) + user.put() + + session['user_id'] = user.key.id() + flask.flash('Registration successful!', 'success') + return redirect(url_for('timeline')) + + return flask.render_template('register.html') + + +@app.route('/login', methods=['GET', 'POST']) +@ndb_context +def login(): + """User login page.""" + if 'user_id' in session: + return redirect(url_for('timeline')) + + if request.method == 'POST': + email = request.form.get('email') + password = request.form.get('password') + + user = User.get_by_email(email) + + if user and user.check_password(password): + session['user_id'] = user.key.id() + flask.flash('Login successful!', 'success') + next_url = request.args.get('next', url_for('timeline')) + return redirect(next_url) + else: + flask.flash('Invalid email or password.', 'danger') + + return flask.render_template('login.html') + + +@app.route('/logout') +def logout(): + """User logout.""" + session.pop('user_id', None) + flask.flash('Logged out successfully.', 'success') + return redirect(url_for('login')) + + +@app.route('/profile', methods=['GET', 'POST']) +@login_required +@ndb_context +def profile(): + """User profile page.""" + user = User.get_by_id(session['user_id']) + + if request.method == 'POST': + display_name = request.form.get('display_name') + + if display_name: + user.display_name = display_name + user.put() + flask.flash('Profile updated successfully!', 'success') + + return redirect(url_for('profile')) + + return flask.render_template('profile.html', user=user) + + +# Timeline Routes +@app.route('/') +@app.route('/timeline') +@login_required +@ndb_context +def timeline(): + """Timeline view - main feed of events.""" + events = Event.query().order(-Event.created_at).fetch(50) + return flask.render_template('timeline.html', events=events) + + +@app.route('/event/create', methods=['POST']) +@login_required +@ndb_context +def create_event(): + """Create a new event.""" + title = request.form.get('title') + description = request.form.get('description') + event_date_str = request.form.get('event_date') + event_time = request.form.get('event_time') + location = request.form.get('location') + + # Validation + if not title or not event_date_str: + flask.flash('Title and date are required.', 'danger') + return redirect(url_for('timeline')) + + try: + event_date = datetime.strptime(event_date_str, '%Y-%m-%d').date() + except ValueError: + flask.flash('Invalid date format.', 'danger') + return redirect(url_for('timeline')) + + # Create event + user = User.get_by_id(session['user_id']) + event = Event( + title=title, + description=description, + event_date=event_date, + event_time=event_time, + location=location, + author_key=user.key + ) + event.put() + + flask.flash('Event created successfully!', 'success') + return redirect(url_for('timeline')) + + +@app.route('/event/') +@login_required +@ndb_context +def view_event(event_id): + """View a single event with comments.""" + event = Event.get_by_id(event_id) + + if not event: + flask.flash('Event not found.', 'danger') + return redirect(url_for('timeline')) + + comments = Comment.query(Comment.event_key == event.key).order(Comment.created_at).fetch() + + return flask.render_template('event.html', event=event, comments=comments) + + +@app.route('/event//comment', methods=['POST']) +@login_required +@ndb_context +def add_comment(event_id): + """Add a comment to an event.""" + event = Event.get_by_id(event_id) + + if not event: + flask.flash('Event not found.', 'danger') + return redirect(url_for('timeline')) + + content = request.form.get('content') + + if not content: + flask.flash('Comment cannot be empty.', 'danger') + return redirect(url_for('view_event', event_id=event_id)) + + user = User.get_by_id(session['user_id']) + comment = Comment( + content=content, + author_key=user.key, + event_key=event.key + ) + comment.put() + + flask.flash('Comment added!', 'success') + return redirect(url_for('view_event', event_id=event_id)) + + +@app.route('/event//delete', methods=['POST']) +@login_required +@ndb_context +def delete_event(event_id): + """Delete an event (author or admin only).""" + event = Event.get_by_id(event_id) + + if not event: + flask.flash('Event not found.', 'danger') + return redirect(url_for('timeline')) + + user = User.get_by_id(session['user_id']) + + if event.author_key != user.key and not user.is_admin: + flask.flash('You do not have permission to delete this event.', 'danger') + return redirect(url_for('timeline')) + + # Delete associated comments + comments = Comment.query(Comment.event_key == event.key).fetch() + for comment in comments: + comment.key.delete() + + event.key.delete() + + flask.flash('Event deleted successfully.', 'success') + return redirect(url_for('timeline')) + + +# Calendar Routes +@app.route('/calendar') +@login_required +@ndb_context +def calendar(): + """Calendar view using FullCalendar.""" + return flask.render_template('calendar.html') + + +@app.route('/api/events') +@login_required +@ndb_context +def api_events(): + """API endpoint to get events in FullCalendar format.""" + events = Event.query().fetch() + events_data = [event.to_fullcalendar_dict() for event in events] + return jsonify(events_data) + + +@app.route('/api/event/') +@login_required +@ndb_context +def api_event_detail(event_id): + """API endpoint to get event details with comments.""" + event = Event.get_by_id(event_id) + + if not event: + return jsonify({'error': 'Event not found'}), 404 + + comments = Comment.query(Comment.event_key == event.key).order(Comment.created_at).fetch() + + return jsonify({ + 'event': event.to_dict(), + 'comments': [comment.to_dict() for comment in comments] + }) + + +# Admin Routes +@app.route('/admin') +@admin_required +@ndb_context +def admin_panel(): + """Admin panel.""" + users = User.query().fetch() + events = Event.query().order(-Event.created_at).fetch() + return flask.render_template('admin.html', users=users, events=events) + + +@app.route('/admin/user//toggle-admin', methods=['POST']) +@admin_required +@ndb_context +def toggle_admin(user_id): + """Toggle admin status for a user.""" + user = User.get_by_id(user_id) + + if not user: + flask.flash('User not found.', 'danger') + return redirect(url_for('admin_panel')) + + user.is_admin = not user.is_admin + user.put() + + flask.flash(f'Admin status updated for {user.email}.', 'success') + return redirect(url_for('admin_panel')) + + +# Profile Image Upload +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + + +def allowed_file(filename): + """Check if file extension is allowed.""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + +@app.route('/upload-profile-image', methods=['POST']) +@login_required +@ndb_context +def upload_profile_image(): + """Upload profile image.""" + if 'profile_image' not in request.files: + flask.flash('No file selected.', 'danger') + return redirect(url_for('profile')) + + file = request.files['profile_image'] + + if file.filename == '': + flask.flash('No file selected.', 'danger') + return redirect(url_for('profile')) + + if file and allowed_file(file.filename): + user = User.get_by_id(session['user_id']) + filename = secure_filename(f"{user.key.id()}_{file.filename}") + + # For local development, save to static/uploads + # For production, you'd upload to Google Cloud Storage + upload_folder = os.path.join('static', 'uploads') + os.makedirs(upload_folder, exist_ok=True) + file_path = os.path.join(upload_folder, filename) + file.save(file_path) + + user.profile_image_url = f'/static/uploads/{filename}' + user.put() + + flask.flash('Profile image updated!', 'success') + return redirect(url_for('profile')) + + flask.flash('Invalid file type.', 'danger') + return redirect(url_for('profile')) + + +if __name__ == '__main__': + app.run(host='127.0.0.1', port=8080, debug=True) diff --git a/models.py b/models.py new file mode 100644 index 0000000..3573113 --- /dev/null +++ b/models.py @@ -0,0 +1,130 @@ +# Copyright 2025 Community Calendar +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +from google.cloud import ndb +from datetime import datetime +import hashlib + + +class User(ndb.Model): + """User model for authentication and profile management.""" + email = ndb.StringProperty(required=True) + password_hash = ndb.StringProperty(required=True) + display_name = ndb.StringProperty(required=True) + profile_image_url = ndb.StringProperty(default='/static/img/default-avatar.svg') + is_admin = ndb.BooleanProperty(default=False) + created_at = ndb.DateTimeProperty(auto_now_add=True) + + @classmethod + def get_by_email(cls, email): + """Get user by email address.""" + return cls.query(cls.email == email).get() + + @staticmethod + def hash_password(password, salt=None): + """Hash a password with SHA-256.""" + if salt is None: + salt = 'community-calendar-salt' + return hashlib.sha256(f"{password}{salt}".encode()).hexdigest() + + def check_password(self, password): + """Check if provided password matches stored hash.""" + return self.password_hash == self.hash_password(password) + + def to_dict(self): + """Convert user to dictionary (excluding password).""" + return { + 'id': self.key.id(), + 'email': self.email, + 'display_name': self.display_name, + 'profile_image_url': self.profile_image_url, + 'is_admin': self.is_admin, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + +class Event(ndb.Model): + """Event model for calendar events and timeline posts.""" + title = ndb.StringProperty(required=True) + description = ndb.TextProperty() + event_date = ndb.DateProperty(required=True) + event_time = ndb.StringProperty() # Store as string like "14:30" + location = ndb.StringProperty() + author_key = ndb.KeyProperty(kind=User, required=True) + created_at = ndb.DateTimeProperty(auto_now_add=True) + updated_at = ndb.DateTimeProperty(auto_now=True) + + @property + def author(self): + """Get the author user object.""" + return self.author_key.get() + + def to_dict(self, include_author=True): + """Convert event to dictionary.""" + event_dict = { + 'id': self.key.id(), + 'title': self.title, + 'description': self.description, + 'event_date': self.event_date.isoformat() if self.event_date else None, + 'event_time': self.event_time, + 'location': self.location, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + if include_author and self.author: + event_dict['author'] = self.author.to_dict() + + return event_dict + + def to_fullcalendar_dict(self): + """Convert event to FullCalendar format.""" + # Combine date and time for FullCalendar + start = self.event_date.isoformat() + if self.event_time: + start = f"{start}T{self.event_time}" + + return { + 'id': str(self.key.id()), + 'title': self.title, + 'start': start, + 'extendedProps': { + 'description': self.description, + 'location': self.location, + 'author': self.author.to_dict() if self.author else None + } + } + + +class Comment(ndb.Model): + """Comment model for comments on events.""" + content = ndb.TextProperty(required=True) + author_key = ndb.KeyProperty(kind=User, required=True) + event_key = ndb.KeyProperty(kind=Event, required=True) + created_at = ndb.DateTimeProperty(auto_now_add=True) + + @property + def author(self): + """Get the author user object.""" + return self.author_key.get() + + @property + def event(self): + """Get the event object.""" + return self.event_key.get() + + def to_dict(self, include_author=True): + """Convert comment to dictionary.""" + comment_dict = { + 'id': self.key.id(), + 'content': self.content, + 'event_id': self.event_key.id(), + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + if include_author and self.author: + comment_dict['author'] = self.author.to_dict() + + return comment_dict diff --git a/requirements.txt b/requirements.txt index 5536f21..7b6c73f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ -Flask>=2.0 -flask-talisman -flask-seasurf @ https://github.com/maxcountryman/flask-seasurf/archive/f383b482c69e0b0e8064a8eb89305cea3826a7b6.zip -google-cloud-ndb +Flask==3.0.0 +Werkzeug==3.0.1 +https://github.com/google/gae-secure-scaffold-python3/archive/master.zip +google-cloud-ndb==2.2.1 +google-cloud-storage==2.10.0 +flask-talisman==1.1.0 +flask-seasurf==1.1.1 +Pillow==10.1.0 +python-dateutil==2.8.2 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..51cedd2 --- /dev/null +++ b/run.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Copyright 2025 Community Calendar +# +# Local development server script + +# Start the Cloud Datastore Emulator +echo "Starting Cloud Datastore Emulator..." +gcloud beta emulators datastore start --no-store-on-disk --consistency=1.0 --host-port=localhost:8081 & +DATASTORE_PID=$! + +# Wait for emulator to start +sleep 3 + +# Set environment variables for the emulator +$(gcloud beta emulators datastore env-init) + +# Install dependencies if needed +if [ ! -d "venv" ]; then + echo "Creating virtual environment..." + python3 -m venv venv +fi + +source venv/bin/activate +pip install -r requirements.txt + +# Run the Flask development server +echo "Starting Flask development server..." +export DATASTORE_EMULATOR_HOST=localhost:8081 +export DATASTORE_PROJECT_ID=community-calendar-dev +python main.py + +# Cleanup: kill the datastore emulator when Flask server stops +kill $DATASTORE_PID diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..b5d7807 --- /dev/null +++ b/settings.py @@ -0,0 +1,46 @@ +# Copyright 2025 Community Calendar +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +# Flask application settings + +import os + +# Security settings +SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') + +# CSP Configuration for FullCalendar and external resources +CSP_POLICY = { + "default-src": "'self'", + "script-src": [ + "'self'", + "https://cdn.jsdelivr.net", + "https://cdnjs.cloudflare.com", + ], + "style-src": [ + "'self'", + "https://cdn.jsdelivr.net", + "https://cdnjs.cloudflare.com", + ], + "img-src": [ + "'self'", + "data:", + "blob:", + ], + "font-src": [ + "'self'", + "https://cdnjs.cloudflare.com", + ], + "connect-src": "'self'", +} + +CSP_POLICY_NONCE_IN = ["script-src", "style-src"] + +# Session configuration +SESSION_COOKIE_SECURE = True +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' + +# File upload configuration +MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB max file size diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..1a7a509 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,156 @@ +/* Community Calendar Custom Styles */ + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #f8f9fa; +} + +/* Navbar customization */ +.navbar-brand { + font-weight: bold; + font-size: 1.4rem; +} + +.navbar-dark .navbar-nav .nav-link { + color: rgba(255, 255, 255, 0.9); + transition: color 0.2s; +} + +.navbar-dark .navbar-nav .nav-link:hover { + color: #ffffff; +} + +/* Card styles */ +.card { + border: none; + border-radius: 10px; + margin-bottom: 1.5rem; +} + +.card-header { + background-color: #fff; + border-bottom: 2px solid #e9ecef; + font-weight: 600; +} + +/* Profile images */ +.rounded-circle { + border: 2px solid #e9ecef; +} + +/* Timeline specific styles */ +.event-details { + background-color: #f8f9fa; + padding: 15px; + border-radius: 8px; + border-left: 4px solid #0d6efd; +} + +/* Button customization */ +.btn { + border-radius: 6px; + padding: 8px 16px; + font-weight: 500; + transition: all 0.2s; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +/* Form styling */ +.form-control, .form-select { + border-radius: 6px; + border: 1px solid #dee2e6; + padding: 10px 12px; +} + +.form-control:focus, .form-select:focus { + border-color: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15); +} + +/* Calendar specific styles */ +#calendar { + max-width: 100%; + margin: 0 auto; +} + +.fc-event { + cursor: pointer; + border-radius: 4px; + padding: 2px 4px; +} + +.fc-event:hover { + opacity: 0.85; +} + +/* Modal customization */ +.modal-content { + border-radius: 10px; + border: none; +} + +.modal-header { + background-color: #0d6efd; + color: white; + border-radius: 10px 10px 0 0; +} + +.modal-header .btn-close { + filter: brightness(0) invert(1); +} + +/* Alert customization */ +.alert { + border-radius: 8px; + border: none; +} + +/* Footer */ +footer { + margin-top: auto; +} + +/* Utility classes */ +.shadow { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important; +} + +.gap-2 { + gap: 0.5rem; +} + +/* Admin panel specific */ +.table-responsive { + border-radius: 8px; +} + +.table thead th { + background-color: #f8f9fa; + font-weight: 600; + border-bottom: 2px solid #dee2e6; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .navbar-brand { + font-size: 1.2rem; + } + + .card-body { + padding: 1rem; + } + + h2 { + font-size: 1.5rem; + } +} + +/* Loading spinner */ +.spinner-border { + width: 3rem; + height: 3rem; +} diff --git a/static/img/default-avatar.png b/static/img/default-avatar.png new file mode 100644 index 0000000..0191ee0 --- /dev/null +++ b/static/img/default-avatar.png @@ -0,0 +1,2 @@ + + diff --git a/static/img/default-avatar.svg b/static/img/default-avatar.svg new file mode 100644 index 0000000..fa11aae --- /dev/null +++ b/static/img/default-avatar.svg @@ -0,0 +1,6 @@ + + + + + No Image + diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..c1774cd --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} + +{% block title %}Admin Panel - Community Calendar{% endblock %} + +{% block content %} +
+
+

+ Admin Panel +

+ + +
+
+
+
+
+ Total Users +
+

{{ users|length }}

+
+
+
+
+
+
+
+ Total Events +
+

{{ events|length }}

+
+
+
+
+
+
+
+ Admins +
+

{{ users|selectattr('is_admin')|list|length }}

+
+
+
+
+ + +
+
+
+ User Management +
+
+
+
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
ProfileNameEmailRoleJoinedActions
+ {{ user.display_name }} + {{ user.display_name }}{{ user.email }} + {% if user.is_admin %} + Admin + {% else %} + User + {% endif %} + {{ user.created_at.strftime('%b %d, %Y') }} + {% if user.key.id() != current_user.key.id() %} +
+ + +
+ {% else %} + (You) + {% endif %} +
+
+
+
+ + +
+
+
+ Recent Events +
+
+
+
+ + + + + + + + + + + + {% for event in events[:10] %} + + + + + + + + {% endfor %} + +
TitleAuthorEvent DateCreatedActions
+ + {{ event.title }} + + {{ event.author.display_name }}{{ event.event_date.strftime('%b %d, %Y') }}{{ event.created_at.strftime('%b %d, %Y') }} + + View + +
+ + +
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..cf57ea0 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,125 @@ + + + + + + {% block title %}Community Calendar{% endblock %} + + + + + + + + + + + {% block extra_head %}{% endblock %} + + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+ {% block content %}{% endblock %} +
+ + +
+
+

Community Calendar © 2025 | Built with Flask & Google App Engine

+
+
+ + + + + {% block extra_scripts %}{% endblock %} + + diff --git a/templates/calendar.html b/templates/calendar.html new file mode 100644 index 0000000..12d28d4 --- /dev/null +++ b/templates/calendar.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} + +{% block title %}Calendar - Community Calendar{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+

+ Community Calendar +

+ +
+
+
+
+
+
+
+ + + +{% endblock %} + +{% block extra_scripts %} + + + + +{% endblock %} diff --git a/templates/event.html b/templates/event.html new file mode 100644 index 0000000..3a5aace --- /dev/null +++ b/templates/event.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} + +{% block title %}{{ event.title }} - Community Calendar{% endblock %} + +{% block content %} +
+
+ + Back to Timeline + + + +
+
+
+ {{ event.author.display_name }} +
+
{{ event.author.display_name }}
+ + Posted on {{ event.created_at.strftime('%B %d, %Y at %I:%M %p') }} + +
+
+ +

+ + {{ event.title }} +

+ + {% if event.description %} +

{{ event.description }}

+ {% endif %} + +
+ +
+

+ + Date: + {{ event.event_date.strftime('%A, %B %d, %Y') }} + {% if event.event_time %} + at {{ event.event_time }} + {% endif %} +

+ + {% if event.location %} +

+ + Location: {{ event.location }} +

+ {% endif %} +
+ + {% if current_user.key == event.author_key or current_user.is_admin %} +
+
+ + +
+ {% endif %} +
+
+ + +
+
+
+ + Comments ({{ comments|length }}) +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if comments %} + {% for comment in comments %} +
+ {{ comment.author.display_name }} +
+
+ {{ comment.author.display_name }} + + {{ comment.created_at.strftime('%B %d, %Y at %I:%M %p') }} + +
+

{{ comment.content }}

+
+
+ {% endfor %} + {% else %} +

+ + No comments yet. Be the first to comment! +

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..43a90e8 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}Login - Community Calendar{% endblock %} + +{% block content %} +
+
+
+
+

+ Login +

+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ +

+ Don't have an account? + Register here +

+
+
+
+
+{% endblock %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..3722aef --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} + +{% block title %}Profile - Community Calendar{% endblock %} + +{% block content %} +
+
+

My Profile

+ +
+
+ Profile Image + +

{{ user.display_name }}

+

{{ user.email }}

+ {% if user.is_admin %} + Admin + {% endif %} +

Member since {{ user.created_at.strftime('%B %d, %Y') }}

+
+
+ + +
+
+
Update Profile
+
+
+
+ + +
+ + +
+ + +
+
+
+ + +
+
+
Change Profile Image
+
+
+
+ + +
+ + + Accepted formats: PNG, JPG, JPEG, GIF +
+ + +
+
+
+
+
+{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..a0d61dd --- /dev/null +++ b/templates/register.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} + +{% block title %}Register - Community Calendar{% endblock %} + +{% block content %} +
+
+
+
+

+ Create Account +

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + + At least 6 characters +
+ +
+ + +
+ + +
+ +
+ +

+ Already have an account? + Login here +

+
+
+
+
+{% endblock %} diff --git a/templates/timeline.html b/templates/timeline.html new file mode 100644 index 0000000..a0bda6b --- /dev/null +++ b/templates/timeline.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} + +{% block title %}Timeline - Community Calendar{% endblock %} + +{% block content %} +
+
+

Community Timeline

+ + +
+
+
+ Create New Event +
+
+
+
+ + +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+
+
+ + +

Recent Events

+ + {% if events %} + {% for event in events %} +
+
+
+ {{ event.author.display_name }} +
+
{{ event.author.display_name }}
+ + {{ event.created_at.strftime('%B %d, %Y at %I:%M %p') }} + +
+
+ +
+ + {{ event.title }} +
+ + {% if event.description %} +

{{ event.description }}

+ {% endif %} + +
+

+ + Date: + {{ event.event_date.strftime('%A, %B %d, %Y') }} + {% if event.event_time %} + at {{ event.event_time }} + {% endif %} +

+ + {% if event.location %} +

+ + Location: {{ event.location }} +

+ {% endif %} +
+ +
+ + View & Comment + + + {% if current_user.key == event.author_key or current_user.is_admin %} +
+ + +
+ {% endif %} +
+
+
+ {% endfor %} + {% else %} +
+ + No events yet. Be the first to create one! +
+ {% endif %} +
+
+{% endblock %}