This commit is contained in:
2025-06-27 17:43:50 -05:00
parent 00fd49c79b
commit 13d56066ab
22 changed files with 1500 additions and 497 deletions

375
README.md
View File

@ -1,179 +1,320 @@
# 🌿 Nature In Pots — Ultra Plant Tracker Platform # Nature In Pots Community Site
> Modular, collaborative, end-to-end plant tracking and pricing platform with QR+barcode IDs, propagation logs, resale tools, and role-based management. ![License](https://img.shields.io/badge/license-MIT-blue) ![Flask](https://img.shields.io/badge/Flask-2.2-green) ![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-1.4-blue) ![Neo4j](https://img.shields.io/badge/Neo4j-5.0-yellow)
A modular, plugin-driven platform for tracking plants, propagation lineage, growth logs, pricing, and community submissions—built with Flask, MySQL, and Neo4j.
--- ---
## 🧩 Overview ## 📌 Table of Contents
This is a full-feature, plugin-driven Flask 2+ web application for managing and tracking plant ownership, propagation, pricing, and growth. It supports dynamic plant attributes, collaboration groups, trade logs, QR/barcode labeling, resale workflows, offline sync, moderator tools, and future AI/ML modules. 1. [Introduction & Goals](#introduction--goals)
2. [Architecture & Tech Stack](#architecture--tech-stack)
3. [Quickstart & Installation](#quickstart--installation)
4. [Project Structure](#project-structure)
5. [Core Features](#core-features)
Built for hobbyists, businesses, breeders, and community gardens. 1. [Plant Profiles](#plant-profiles)
2. [Grow Logs](#grow-logs)
3. [Verified Lineage Tracking](#verified-lineage-tracking)
4. [Pricing Logic](#pricing-logic)
5. [Substrate & Fertilizer Tracking](#substrate--fertilizer-tracking)
6. [Shipping Tracker](#shipping-tracker)
7. [Plant Folders](#plant-folders)
8. [Media Gallery & Voting](#media-gallery--voting)
9. [QR & Barcode Labeling](#qr--barcode-labeling)
10. [Offline Sync (PWA)](#offline-sync-pwa)
11. [Smart Tools](#smart-tools)
6. [Plugin Ecosystem](#plugin-ecosystem)
7. [Data Models & Schema](#data-models--schema)
8. [APIs & CLI](#apis--cli)
9. [User Interface & PWA](#user-interface--pwa)
10. [Security & Privacy](#security--privacy)
11. [Permissions Matrix](#permissions-matrix)
12. [Admin & Dev Tools](#admin--dev-tools)
13. [Internationalization](#internationalization)
14. [Roadmap & Future Enhancements](#roadmap--future-enhancements)
15. [Contributing](#contributing)
16. [License](#license)
--- ---
## 🚀 Core Features ## 📝 Introduction & Goals
Nature In Pots empowers hobbyists, breeders, and businesses to:
* **Create & manage** rich plant profiles (names, lineage, pricing, notes).
* **Track growth** via logs with metrics, health/pest events, and custom traits.
* **Visualize propagation** on a Neo4j-powered ancestry graph.
* **Upload & curate** images—community-voted, featured selections.
* **Handle secure transfers** of ownership, preserving full history and privacy.
* **Organize** plants into folders with shareable QR/back-barcodes.
* **Import & export** comprehensive CSV/ZIP bundles, including media.
* **Extend** via plugins: materials, ledger, inventory, vendor collectives, and more.
---
## 🏗 Architecture & Tech Stack
* **Backend**: Python 3.11, Flask 2+, Flask-Login, Flask-Migrate (Alembic)
* **ORM**: SQLAlchemy (MySQL) & Neo4j Bolt driver
* **Storage**: Local FS (`UPLOAD_FOLDER`), optional S3 sync
* **Frontend**: Bootstrap 5, responsive + PWA caching
* **QR/Barcode**: Pillow + qrcode, CODE-128 barcodes
* **Security**: End-to-End encryption with optional key escrow
* **Testing**: pytest + coverage; CI/CD via GitHub Actions
* **Containerization**: Docker & Docker Compose
---
## 🚀 Quickstart & Installation
1. **Clone**
```bash
git clone https://github.com/yourorg/nip-community.git
cd nip-community
```
2. **Virtualenv & Dependencies**
```bash
python3 -m venv venv; source venv/bin/activate
pip install -r requirements.txt
```
3. **Env & Config**
Copy `.env.example` → `.env`, set `FLASK_APP=app`, DB/Neo4j URLs, `SECRET_KEY`, `UPLOAD_FOLDER`.
4. **Migrations**
```bash
flask db init
flask db migrate
flask db upgrade
```
5. **Run**
```bash
flask run --reload
```
Access [http://localhost:5000](http://localhost:5000)
---
## 📂 Project Structure
```
.
├── app/ # Core app factory, auth, errors, Neo4j utils
├── plugins/ # Feature modules (plant, media, utility, etc.)
│ ├── plant/
│ ├── media/
│ ├── utility/
│ ├── growlog/
│ ├── submissions/
│ ├── vendor/
│ └── (future: materials, ledger, inventory, collective)
├── migrations/ # Alembic scripts
├── static/ # Global assets
├── templates/ # Shared base templates
├── tests/ # pytest suite
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── README.md # ← this file
```
---
## ⭐ Core Features
### 🌱 Plant Profiles ### 🌱 Plant Profiles
- Add, edit, and log plants with full propagation and ownership history
- Each plant is assigned a permanent, scannable **QR code** and **barcode**
- Plants can be marked as `Public`, `Unlisted`, or `Folder-only`
- All pricing, logs, and lineage are tied to a plant ID and user
### 🧾 Grow Logs * **Create/Edit** plants with:
- Add logs with images, notes, and growth metrics
- Track success/failure events, mutation events, pest/disease sightings * UUID & custom slug
- Link logs to substrate recipes and fertilizers * Plant type (seed, cutting, tissue culture, division, etc.)
* Common & scientific names (with lookup/autocomplete)
* Vendor, notes, active flag
* Parent (`mother_uuid`) references
* **Detail page** shows metadata, lineage nav, QR links, folders.
### 📓 Grow Logs
* Timestamped logs with:
* Size, leaf count, substrate mix, potting notes
* Health/pest/disease events & treatments
* Up to 5 images per log
* **Timeline** view on plant detail.
### 🔗 Verified Lineage Tracking ### 🔗 Verified Lineage Tracking
- Lineage links are created by the new owner
- Parent plant's owner must **approve** the linkage * **Neo4j** graph for parent→child relationships.
- Verified lineage is marked with a badge and shown in plant lineage tree * Pending link requests: parent-owner approval required.
- Pending links are visible only to the creator * Verified links get a badge; unverified shown in draft.
### 💰 Pricing Logic ### 💰 Pricing Logic
- Only the current owner and admins can see pricing
- On transfer, original price is retained but hidden from the buyer
- Buyer must submit their own price for tracking resale data
- Admins see full price history; mods and others do not
### 🧪 Substrate + Fertilizer Tracking * **Owner & admin** see full pricing history; others do not.
- Track custom mixes by ingredient (e.g., “Fine Pumice”, “Large Bark”) * On transfer, sellers last price is retained but hidden; buyer sets new price.
- Store cost per ingredient and auto-calculate total mix cost * **Price records** tracked as `PriceHistory` entries.
- Recipes can be reused across plants * Visibility toggles: `public`, `unlisted`, `folder-only`.
- Fertilizer schedules can be attached to logs and outcomes tracked
### 🧪 Substrate & Fertilizer Tracking
* Define **mix recipes**: ingredient list with cost per unit.
* Auto-calculate total mix cost based on usage (e.g., ounces, cups).
* Reusable recipes attachable to grow logs.
* **Fertilizer schedules** logged and outcomes tracked.
### 📦 Shipping Tracker ### 📦 Shipping Tracker
- When a plant is sold, sellers can add:
- Carrier, tracking number, est. delivery date * During sale, seller enters: carrier, tracking number, est. delivery.
- Ownership updates post buyer confirmation * Transfer completes after buyer confirmation.
- Shipping logs are attached to the plant transfer log * Shipping events attached to ownership log.
### 📁 Plant Folders ### 📁 Plant Folders
- Organize plants into folders (e.g., “For Sale”, “2025 Spring Batch”)
- Each folder gets its own QR code: * Organize plants into **named folders** (e.g., “For Sale”, “2025 Seedlings”).
* Each folder has its own QR code and slug:
`https://domain.com/{username}/folder/{id}|{slug}` `https://domain.com/{username}/folder/{id}|{slug}`
- Folders can be public, private, or unlisted * Folder visibility: `public`, `private`, `unlisted`.
### 🖼 Media Gallery & Voting
* **Upload**, rotate, delete images per plant/growlog.
* Per-user “heart” or “broken heart” voting.
* **Featured image** toggle via media plugin; batch fallback to top-voted.
* EXIF data stripped on upload.
### 📇 QR & Barcode Labeling
* **QR codes** (SVG & PNG) unique per plant/folder.
* **Barcode (CODE-128)** fallback; splits long data into stacked barcodes.
* Printable labels via dashboard.
### 📶 Offline Sync (PWA)
* Full form support offline (plants, logs, submissions).
* Sync queue when back online.
* QR/barcode generation deferred until server sync.
### 🤖 Smart Tools
* **Reputation System**: users rated by buyers (accuracy, responsiveness).
* “Trusted Grower” badge auto-granted.
* **Inter-Plant Comparison**: side-by-side charts of growth metrics or events.
--- ---
## 🧍‍♂️ Users, Roles, and Groups ## 🔌 Plugin Ecosystem
### 👤 User Roles | Plugin | Purpose | Status |
- **User** default | --------------- | --------------------------------------------- | ---------- |
- **Moderator** can manage flags, notes | **plant** | Core plant CRUD & lineage | ✅ Live |
- **Admin** full backend, plugin, pricing, banning control | **media** | Image storage, EXIF, voting, featured | ✅ Live |
- Roles are extensible via admin UI | **utility** | Import/Export CSV/ZIP, QR code generation | ✅ Live |
| **growlog** | Growth logs & update images | ✅ Live |
### 🛡 Moderator Panel | **submissions** | Community submissions & voting | ✅ Live |
- View and resolve reports | **vendor** | Vendor/collective profiles, membership, roles | ✅ Live |
- Add private notes to users or plants (e.g., warnings, suspicion) | **materials** | Potting mixes, proprietary flags | 🔜 Planned |
- Ban users | **ledger** | Cost logs, fees, tax, shipping | 🔜 Planned |
- Banned users' plants become read-only | **inventory** | Stock management, restock/depletion | 🔜 Planned |
- Cannot add new plants | **collective** | Shared groups with RBAC | 🔜 Planned |
- Buyers can request transfer via email approval from banned seller
### 👥 Collaboration Groups
- Users can form groups to share:
- Logs
- Images
- Pricing (opt-in)
- Groups have role-based permissions (manager, editor, viewer)
- Useful for stores, teams, or shared collections
--- ---
## 🏷️ Labeling System: QR + Barcode ## 📊 Data Models & Schema
### QR Code Refer to `/migrations/` for full schema. Key tables include:
- Generated on server after initial sync
- Unique to plant, never changes
- SVG and PNG available
### Barcode Fallback * `users`, `roles`, `user_roles`
- CODE-128 format * `plant_common_name`, `plant_scientific_name`
- If data too long, split into stacked barcodes * **`plant`**: FKs → common, scientific, owner, mother\_uuid, featured\_media
- Same encoded info: plant ID, owner ID, visibility, timestamp * `media`: FKs → plant\_id, growlog\_id, uploader
- Printable as label from dashboard * `featured_image`
* `plant_update`, `update_image`
* `plant_ownership_log`, `transfer_request`
* `submission`, `submission_image`
* `health_event`, `trait`, lookup tables
* `vendor_profile`, `vendor_member`, `affiliation_request`, `claim_request`
* `listing`, `price_history`
* `inventory_item`, `restock_event`, `depletion_event`
--- ---
## 🔐 Permissions Matrix ## 🛠 APIs & CLI
| Feature | Owner | Group Member | Moderator | Admin | * **REST**: `/api/v1/` CRUD, OpenAPI docs, JWT auth, rate limiting
|--------------------------|-------|---------------|-----------|--------| * **GraphQL**: `/graphql` batched queries for plants + relations
| View Logs | ✅ | ✅ (if shared) | ✅ | ✅ | * **CLI**:
| Edit Logs | ✅ | 🔁* | 🚫 | ✅ |
| View Pricing | ✅ | ✅ (opt-in) | 🚫 | ✅ |
| Ban User / Flag Review | 🚫 | 🚫 | ✅ | ✅ |
| Approve Lineage | 🚫 | 🚫 | 🚫 | ✅ |
| Confirm Lineage | ✅ | 🚫 | 🚫 | ✅ |
🔁* if granted by group manager * `flask db {init,migrate,upgrade}`
* `flask user create`
* `flask plugin {install,list,enable}`
* `flask preload-data`
--- ---
## 🌍 Offline Sync (PWA) ## 🌐 User Interface & PWA
- Full add/log/edit possible offline
- Sync queue uploads on connection * **Bootstrap 5** responsive layouts
- QR/barcodes generated **after** server confirms sync * **Desktop**: large gallery + thumbnail carousel
- Client-side validation before queue * **Mobile**: grid lightbox with pinch-zoom & close button
* Offline caching via service worker
--- ---
## 🧠 Smart Tools ## 🔒 Security & Privacy
### ⭐️ User Reputation System * **End-to-End Encryption** for notes, logs, pricing
- Users rated after trades (accuracy, responsiveness, helpfulness) * **Key Escrow** (opt-in) vs **Maximum Privacy** (no escrow)
- “Trusted Grower” tag auto-assigned above threshold * **Blind Transfers**: admin can reassign without decryption
- Can be revoked via vote or admin action * **Audit Logs**: immutable record of all actions
* **Role-Based Access**: owner, group member, moderator, admin
### 🌿 Inter-Plant Comparison
- Timeline comparison for growth, size, or log outcomes
- Side-by-side charts and event overlays
--- ---
## 🔧 Admin & Dev Tools ## 🛂 Permissions Matrix
### 📤 Seed Data Generator | Action | Owner | Group | Mod | Admin |
- Seeds with common aroids, herbs, and test users | ----------------------------- | :---: | :---: | :-: | :---: |
- Covers full range of roles, plant types, and edge cases | Create/Edit own plants & logs | ✅ | ✅\* | ✅ | ✅ |
| View public plants/logs | ✅ | ✅ | ✅ | ✅ |
| Approve lineage & submissions | ❌ | ❌ | ✅ | ✅ |
| Manage vendors/collectives | ❌ | ❌ | ❌ | ✅ |
| Transfer override (no-owner) | ❌ | ❌ | ❌ | ✅ |
| Plugin management & settings | ❌ | ❌ | ❌ | ✅ |
### 🗂 Plugin System \* if granted by group manager
- CLI & plugin discovery system
- Admin can toggle plugins
- Plugin types: CLI, UI Panel, API extension, webhook, scheduler
### 🗃 Data Export / Disaster Recovery
- Export: SQL dump, file archive, JSON profile
- Restore: Admin-initiated rollback or full upload restore
--- ---
## 🛣 API System ## Admin & Dev Tools
### REST & GraphQL APIs * **Moderator Panel**: flag review, user notes, bans
- REST: OpenAPI-documented endpoints * **Seed Data Generator**: sample users, plants, logs
- GraphQL: Advanced multi-entity queries * **Plugin CLI**: install/enable/disable plugins at runtime
- JWT-secured * **ERD Viewer**: visualize database schema
- Follows role-based access rules * **Disaster Recovery**: SQL dump + file archive + JSON profiles
--- ---
## 🌐 Internationalization ## 🌍 Internationalization
- Flask-Babel integration
- Language switcher in UI * **Flask-Babel** for translations
- Community-managed translation interface (admin toggled) * Language switcher in UI
* Community-managed translation portal (admin toggle)
--- ---
## 📅 Future Enhancements ## 🛣 Roadmap & Future Enhancements
- 🧠 AI Journal Assistant (log suggestions, summarization) * AI-powered journal assistant & mutation detection
- 📆 Calendar View for logs/reminders * Calendar reminders for watering, fertilizing
- 🧰 Visual ERD Generator Tool * Visual ERD generator tool in admin UI
- 🛒 Live Auctions Plugin (for curated resale events) * Live auction & escrow plugin
* Third-party webhook integrations (Zapier, Discord)
--- ---

151
Security.md Normal file
View File

@ -0,0 +1,151 @@
# 🔐 SECURITY.md
_Nature In Pots Community Data & Platform Security Policy_
---
## 🔒 Overview
This document outlines the security practices, encryption policies, and recovery procedures used by the **Nature In Pots Community** platform. Our goal is to maintain the privacy and integrity of user data while offering secure collaboration, recovery, and administrative tooling where appropriate.
---
## ✅ Security Principles
We follow a simple core policy:
> **Your data is yours.** You choose who sees it, how it's stored, and what happens to it.
To support this, we implement:
- **End-to-End Encryption (E2EE)** for sensitive records.
- **User-controlled privacy flags** and public visibility toggles.
- **Role-based access control** for owners, groups, vendors, and admins.
- **Optional key escrow** to allow secure, auditable recovery when necessary.
- **Strict audit trails** for all user and admin actions.
- **Granular ownership transfers** that preserve privacy unless explicitly permitted.
---
## 🧑‍🌾 User Data Encryption
When a user creates or imports plants, grow logs, health reports, media, or related records, the following options apply for encryption and recovery:
### 🔐 Option 1: Maximum Privacy Mode (No Escrow)
- Data is encrypted with a user-owned key never shared with the server.
- Only the user can decrypt or transfer ownership.
- If the key is lost, **data is unrecoverable**. Even admins cannot assist.
### 🗝️ Option 2: Recovery-Enabled Mode (Escrow)
- A securely wrapped recovery key is stored using *key escrow*.
- Admins may **transfer ownership** of encrypted records to a new user (e.g., due to account inactivity or verified reassignment), but cannot read or decrypt the data.
- All access and recovery actions are **audited** and must be explicitly justified.
Users are prompted to choose between these options on signup and may change their preference at any time from **Account Settings → Privacy Options**.
#### TL;DR (Explain Like Im 5)
- **Full Privacy Mode**: Only you have the key to your secret garden. Lose it = locked out forever.
- **Recovery Mode (Default)**: Still private, but we keep a spare key in a vault. We can give your garden to a new gardener if needed, but we still cant peek inside.
---
## 🧑‍🤝‍🧑 Groups, Vendors, and Collectives
A "collective identity" (e.g., vendor, grow group, brand) may be created and owned by one or more users.
- Each collective has its own permission rules.
- Permissions include: `read`, `edit`, `propose changes`, `submit grow logs`, `mark as sold`, and `manage members`.
- Collective-owned content inherits the encryption mode of the creator or the designated owner.
All actions taken under a collective identity are tagged with the acting user and timestamped.
---
## 🔁 Ownership & Record Transfers
Records (plants, logs, vendors, mixes, etc.) can be transferred between:
- Individuals ↔ Individuals
- Individuals ↔ Vendors / Groups
- Vendors ↔ Vendors
**Transfer rules:**
| Encryption Mode | Owner Action Required | Admin Recovery Allowed |
|------------------|------------------------|-------------------------|
| No Escrow | ✅ Yes | ❌ No |
| Escrow Enabled | 🚫 Optional (if owner inactive) | ✅ Yes |
All transfers are logged with before/after states and include initiator ID, reason, and timestamp.
---
## 🪵 Audit Logging
All significant actions are audit-logged:
- Logins, failed logins, and 2FA attempts
- Data modifications (create, edit, delete)
- Media uploads
- Grow log entries
- Transfers and permission changes
- Escrow-based recoveries
Logs are not publicly accessible but may be disclosed to the user on request or subpoena.
---
## ⚠️ Admin Privileges & Limitations
Admins can:
- Approve or remove public content
- Recover records **only** under escrow-enabled mode
- View metadata (timestamps, image hashes, plant IDs)
- **Not** decrypt user content in maximum privacy mode
- **Not** alter audit trails or impersonate users
---
## 🧪 Developer Guidelines
### Secret Management
- All crypto secrets must be kept in `.env` or secure vaults.
- Never commit user-generated keys or tokens to Git.
### Input Sanitization
- All user inputs are escaped before rendering.
- Media uploads are validated and stripped of EXIF/GPS metadata.
### TLS & HTTPS
- All public and private routes must use HTTPS.
- Local dev servers use self-signed certs or SSL proxy.
---
## 🛡️ Future Features (Planned)
- ✅ Invite-only registration support
- ✅ Per-record expiration and auto-archival
- 🔒 Self-destructing records (for sensitive notes)
- 🧬 Cryptographic signatures on propagation history
- 🧾 Printable audit exports per plant/vendor
---
## 📞 Contact & Reporting Security Issues
If you find a vulnerability or need to report a breach:
- Email: [security@natureinpots.com](mailto:security@natureinpots.com)
- PGP Key: Coming soon
- Please include reproduction steps and affected data
---
_This document is maintained by the Nature In Pots security team. Last updated: {{ current_year }}._

View File

@ -4,7 +4,7 @@ bp = Blueprint('errors', __name__)
@bp.app_errorhandler(400) @bp.app_errorhandler(400)
def bad_request(error): def bad_request(error):
return render_template('400.html'), 400 return render_template('400.html', error=error), 400
@bp.app_errorhandler(404) @bp.app_errorhandler(404)
def bad_request(error): def bad_request(error):

View File

@ -24,9 +24,7 @@ fi
# Autogenerate new migration if needed # Autogenerate new migration if needed
echo "[🛠️] Checking for new schema changes" echo "[🛠️] Checking for new schema changes"
if ! flask db migrate -m "auto-migrate" --compare-type --render-as-batch; then flask db migrate -m "auto-migrate" || echo "[] No schema changes detected"
echo "[] No schema changes detected"
fi
# Apply migrations # Apply migrations
echo "[▶️] Applying database migrations" echo "[▶️] Applying database migrations"

View File

@ -53,7 +53,7 @@ def run_migrations_online():
target_metadata=target_metadata, target_metadata=target_metadata,
compare_type=True, compare_type=True,
sort_tables=True, sort_tables=True,
render_as_batch=True, # ✅ important! render_as_batch=True,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 209596f02c2a
Revises: 42ce181f4eab
Create Date: 2025-06-27 09:50:03.962692
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '209596f02c2a'
down_revision = '42ce181f4eab'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 42ce181f4eab
Revises: 93f8a5cbc643
Create Date: 2025-06-27 09:47:28.698481
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '42ce181f4eab'
down_revision = '93f8a5cbc643'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 93f8a5cbc643
Revises: 9cc2626a6e79
Create Date: 2025-06-27 09:31:27.528072
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '93f8a5cbc643'
down_revision = '9cc2626a6e79'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 9cc2626a6e79
Revises: d54a88422a68
Create Date: 2025-06-27 09:28:40.656166
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9cc2626a6e79'
down_revision = 'd54a88422a68'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: d54a88422a68
Revises: d7bbffbbc931
Create Date: 2025-06-27 09:24:27.947480
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd54a88422a68'
down_revision = 'd7bbffbbc931'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""auto-migrate
Revision ID: d7bbffbbc931
Revises:
Create Date: 2025-06-27 09:20:35.600333
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd7bbffbbc931'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('grow_logs', schema=None) as batch_op:
batch_op.add_column(sa.Column('event_type', sa.String(length=50), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('grow_logs', schema=None) as batch_op:
batch_op.drop_column('event_type')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: f00a9585a348
Revises: 209596f02c2a
Create Date: 2025-06-27 09:55:08.249023
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f00a9585a348'
down_revision = '209596f02c2a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

BIN
nip.zip

Binary file not shown.

View File

@ -2,89 +2,203 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Nature In Pots Community{% endblock %}</title> <title>{% block title %}Nature In Pots Community{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
rel="stylesheet"
>
<style> <style>
body { body { display: flex; flex-direction: column; min-height: 100vh; }
display: flex; main { flex: 1; }
flex-direction: column; footer { background: #f8f9fa; padding: 1rem 0; text-align: center; }
min-height: 100vh;
}
main {
flex: 1;
}
footer {
background-color: #f8f9fa;
padding: 1rem 0;
text-align: center;
}
</style> </style>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4"> <nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm mb-4">
<div class="container"> <div class="container">
<a class="navbar-brand" href="{{ url_for('core_ui.home') }}">Nature In Pots</a> <a class="navbar-brand fw-bold" href="{{ url_for('core_ui.home') }}">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> Nature In Pots
</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#mainNav"
>
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto"> <div class="collapse navbar-collapse" id="mainNav">
<li class="nav-item"> <!-- Left links -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a> <a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a>
</li> </li>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item"> <li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a> <a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a>
</li> </li>
<li class="nav-item"> <li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('utility.upload') }}">Import</a> <a class="nav-link" href="{{ url_for('growlog.list_logs') }}">Grow Logs</a>
</li> </li>
<li class="nav-item"> <li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('plant.index') }}#plantContainer">Grow Logs</a> <a class="nav-link" href="{{ url_for('submission.list_submissions') }}">
</li> Submissions
<li class="nav-item">
<a class="nav-link" href="{{ url_for('submission.list_submissions') }}">Submissions</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('utility.export_data') }}">Export</a>
</li>
{% endif %}
{% if current_user.is_authenticated and current_user.role == 'admin' %}
<li class="nav-item">
<a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">
Admin Dashboard
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% block plugin_links %}{% endblock %}
</ul> </ul>
<ul class="navbar-nav align-items-center">
<!-- New-item + Admin + Plugins -->
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item ms-3"> <div class="d-flex align-items-center">
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a> <!-- + New dropdown -->
<div class="btn-group me-3">
<button
type="button"
class="btn btn-success"
onclick="location.href='{{ url_for('plant.create') }}'"
>
<i class="bi bi-plus-lg"></i>
</button>
<button
type="button"
class="btn btn-success dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle menu</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('plant.create') }}">
New Plant
</a>
</li> </li>
<li>
<a class="dropdown-item" href="{{ url_for('growlog.add_log') }}">
New Grow Log
</a>
</li>
<li>
<a
class="dropdown-item"
href="{{ url_for('submission.new_submission') }}"
>
New Submission
</a>
</li>
</ul>
</div>
<!-- Admin link -->
{% if current_user.role == 'admin' %}
<a
class="btn btn-outline-danger me-3"
href="{{ url_for('admin.admin_dashboard') }}"
>
Admin Dashboard
</a>
{% endif %}
<!-- Plugins dropdown -->
<div class="dropdown me-3">
<a
class="btn btn-outline-secondary dropdown-toggle"
href="#"
id="pluginsDropdown"
role="button"
data-bs-toggle="dropdown"
>
Plugins
</a>
<ul
class="dropdown-menu dropdown-menu-end"
aria-labelledby="pluginsDropdown"
>
<li><a class="dropdown-item" href="#">Materials</a></li>
<li><a class="dropdown-item" href="#">Ledger</a></li>
<li><a class="dropdown-item" href="#">Inventory</a></li>
<li><a class="dropdown-item" href="#">Collectives</a></li>
</ul>
</div>
<!-- Profile dropdown -->
<div class="dropdown">
<a
class="d-flex align-items-center text-decoration-none dropdown-toggle"
href="#"
id="profileDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="bi bi-person-circle fs-4 me-1"></i>
<span>{{ current_user.username }}</span>
</a>
<ul
class="dropdown-menu dropdown-menu-end text-small"
aria-labelledby="profileDropdown"
>
<li><a class="dropdown-item" href="#">My Profile</a></li>
<li><a class="dropdown-item" href="#">Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('utility.upload') }}">
Import
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('utility.export_data') }}">
Export
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a
class="dropdown-item text-danger"
href="{{ url_for('auth.logout') }}"
>
<i class="bi bi-box-arrow-right me-1"></i> Logout
</a>
</li>
</ul>
</div>
</div>
{% else %} {% else %}
<li class="nav-item ms-3"> <!-- Login / Register buttons -->
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a> <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item me-2">
<a class="btn btn-outline-primary" href="{{ url_for('auth.login') }}">
Login
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">Register</a> <a class="btn btn-primary" href="{{ url_for('auth.register') }}">
Register
</a>
</li> </li>
{% endif %}
</ul> </ul>
{% endif %}
</div> </div>
</div> </div>
</nav> </nav>
<main class="container"> <main class="container">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="container mt-3"> <div class="mt-3">
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert"> <div
class="alert alert-{{ category }} alert-dismissible fade show"
role="alert"
>
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -92,9 +206,13 @@
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer> <footer>
&copy; {{ current_year | default(2025) }} Nature In Pots Community. All rights reserved. &copy; {{ current_year | default(2025) }} Nature In Pots Community. All rights reserved.
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
></script>
</body> </body>
</html> </html>

View File

@ -1,6 +1,55 @@
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block title %}Home | Nature In Pots{% endblock %} {% block title %}Home | Nature In Pots{% endblock %}
{% block content %} {% block content %}
<h1 class="mb-4">Welcome to Nature In Pots 🌿</h1> <!-- Hero Section -->
<p>This is the community hub for plant tracking, propagation history, and price sharing.</p> <div class="py-5 text-center bg-light rounded-3">
<h1 class="display-5 fw-bold">Welcome to Nature In Pots</h1>
<p class="fs-5 text-muted mb-4">
Your internal platform for comprehensive plant tracking, propagation history, and collaborative logging.
</p>
<p class="mb-0">
<strong class="text-success">Free to use for the time being</strong><br>
(A future subscription or licensing model may be introduced as needed.)
</p>
</div>
<!-- Features Overview -->
<div class="row mt-5 gy-4">
<div class="col-md-4">
<h3>Plant Profiles</h3>
<p>Quickly create and manage detailed records—type, names, lineage, notes, and custom slugs for easy sharing.</p>
</div>
<div class="col-md-4">
<h3>Grow Logs</h3>
<p>Maintain a timeline of growth metrics, health events, substrate mixes, and propagation notes.</p>
</div>
<div class="col-md-4">
<h3>Image Gallery</h3>
<p>Upload, rotate, and feature photos for each plant. Community voting and one-click “featured” selection.</p>
</div>
<div class="col-md-4">
<h3>Lineage Tracking</h3>
<p>Visualize parentchild relationships with a graph powered by Neo4j—track every cutting, seed, and division.</p>
</div>
<div class="col-md-4">
<h3>Pricing & Transfers</h3>
<p>Securely log acquisition costs, resale prices, and ownership changes—with history retained and protected.</p>
</div>
<div class="col-md-4">
<h3>Import & Export</h3>
<p>Bulk-import plants and media via CSV/ZIP. Export your entire dataset and images for backups or reporting.</p>
</div>
</div>
<!-- Call to Action -->
<div class="text-center mt-5">
{% if current_user.is_authenticated %}
<a href="{{ url_for('plant.index') }}" class="btn btn-primary btn-lg me-2">View My Plants</a>
<a href="{{ url_for('utility.upload') }}" class="btn btn-outline-secondary btn-lg">Import Data</a>
{% else %}
<a href="{{ url_for('auth.register') }}" class="btn btn-success btn-lg me-2">Register Now</a>
<a href="{{ url_for('auth.login') }}" class="btn btn-outline-primary btn-lg">Log In</a>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -1,8 +1,16 @@
# plugins/growlog/forms.py
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import TextAreaField, SelectField, SubmitField from wtforms import SelectField, StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length from wtforms.validators import DataRequired, Length
class GrowLogForm(FlaskForm): class GrowLogForm(FlaskForm):
plant_uuid = SelectField(
'Plant',
choices=[], # injected in view
validators=[DataRequired()]
)
event_type = SelectField('Event Type', choices=[ event_type = SelectField('Event Type', choices=[
('water', 'Watered'), ('water', 'Watered'),
('fertilizer', 'Fertilized'), ('fertilizer', 'Fertilized'),
@ -11,5 +19,7 @@ class GrowLogForm(FlaskForm):
('pest', 'Pest Observed') ('pest', 'Pest Observed')
], validators=[DataRequired()]) ], validators=[DataRequired()])
note = TextAreaField('Notes', validators=[Length(max=1000)]) title = StringField('Title', validators=[Length(max=255)])
notes = TextAreaField('Notes', validators=[Length(max=1000)])
is_public = BooleanField('Public?')
submit = SubmitField('Add Log') submit = SubmitField('Add Log')

View File

@ -7,6 +7,7 @@ class GrowLog(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=False) plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=False)
event_type = db.Column(db.String(50), nullable=False)
title = db.Column(db.String(255), nullable=True) title = db.Column(db.String(255), nullable=True)
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
is_public = db.Column(db.Boolean, default=False, nullable=False) is_public = db.Column(db.Boolean, default=False, nullable=False)

View File

@ -1,31 +1,176 @@
from flask import Blueprint, render_template, redirect, url_for, request from uuid import UUID as _UUID
from flask_login import login_required from werkzeug.exceptions import NotFound
from flask import (
Blueprint, render_template, abort, redirect, url_for, request, flash
)
from flask_login import login_required, current_user
from app import db from app import db
from .models import GrowLog from .models import GrowLog
from .forms import GrowLogForm from .forms import GrowLogForm
from plugins.plant.models import Plant from plugins.plant.models import Plant, PlantCommonName
bp = Blueprint('growlog', __name__, template_folder='templates')
@bp.route('/plants/<int:plant_id>/logs') bp = Blueprint(
'growlog',
__name__,
url_prefix='/growlogs',
template_folder='templates',
)
def _get_plant_by_uuid(uuid_val):
"""
uuid_val may already be a uuid.UUID (from a <uuid:> route converter)
or a string (from form POST). Normalize & validate it, then lookup.
"""
# 1) If Flask route gave us a UUID instance, just stringify it
if isinstance(uuid_val, _UUID):
val = str(uuid_val)
else:
# 2) Otherwise try to parse it as a hex string
try:
val = str(_UUID(uuid_val))
except (ValueError, TypeError):
# invalid format → 404
abort(404)
# 3) Only return plants owned by current_user
return (
Plant.query
.filter_by(uuid=val, owner_id=current_user.id)
.first_or_404()
)
def _user_plant_choices():
# join to the commonname table and sort by its name
plants = (
Plant.query
.filter_by(owner_id=current_user.id)
.join(PlantCommonName, Plant.common_id == PlantCommonName.id)
.order_by(PlantCommonName.name)
.all()
)
return [
(p.uuid, f"{p.common_name.name} {p.uuid}")
for p in plants
]
@bp.route('/add', methods=['GET','POST'])
@bp.route('/<uuid:plant_uuid>/add', methods=['GET','POST'])
@login_required @login_required
def view_logs(plant_id): def add_log(plant_uuid=None):
plant = Plant.query.get_or_404(plant_id)
logs = GrowLog.query.filter_by(plant_id=plant.id).order_by(GrowLog.timestamp.desc()).all()
return render_template('growlog/log_list.html', plant=plant, logs=logs)
@bp.route('/plants/<int:plant_id>/logs/add', methods=['GET', 'POST'])
@login_required
def add_log(plant_id):
plant = Plant.query.get_or_404(plant_id)
form = GrowLogForm() form = GrowLogForm()
# 1) always populate the dropdown behind the scenes
form.plant_uuid.choices = _user_plant_choices()
plant = None
hide_select = False
# 2) if URL had a plant_uuid, load & pre-select it, hide dropdown
if plant_uuid:
plant = _get_plant_by_uuid(plant_uuid)
form.plant_uuid.data = str(plant_uuid)
hide_select = True
if form.validate_on_submit(): if form.validate_on_submit():
# 3) on POST, resolve via form.plant_uuid
plant = _get_plant_by_uuid(form.plant_uuid.data)
log = GrowLog( log = GrowLog(
plant_id = plant.id, plant_id = plant.id,
event_type = form.event_type.data, event_type = form.event_type.data,
note=form.note.data title = form.title.data,
notes = form.notes.data,
is_public = form.is_public.data,
) )
db.session.add(log) db.session.add(log)
db.session.commit() db.session.commit()
return redirect(url_for('growlog.view_logs', plant_id=plant.id)) flash('Grow log added.', 'success')
return render_template('growlog/log_form.html', form=form, plant=plant) return redirect(
url_for('growlog.list_logs', plant_uuid=plant.uuid)
)
return render_template(
'growlog/log_form.html',
form = form,
plant = plant,
hide_plant_select = hide_select
)
@bp.route('/', defaults={'plant_uuid': None})
@bp.route('/<uuid:plant_uuid>')
@login_required
def list_logs(plant_uuid):
# how many to show?
limit = request.args.get('limit', default=10, type=int)
if plant_uuid:
# logs for a single plant
plant = _get_plant_by_uuid(plant_uuid)
query = GrowLog.query.filter_by(plant_id=plant.id)
else:
# logs for all your plants
plant = None
query = (
GrowLog.query
.join(Plant, GrowLog.plant_id == Plant.id)
.filter(Plant.owner_id == current_user.id)
)
logs = (
query
.order_by(GrowLog.created_at.desc())
.limit(limit)
.all()
)
return render_template(
'growlog/log_list.html',
plant=plant,
logs=logs,
limit=limit
)
@bp.route('/<uuid:plant_uuid>/edit/<int:log_id>', methods=['GET', 'POST'])
@login_required
def edit_log(plant_uuid, log_id):
plant = _get_plant_by_uuid(plant_uuid)
log = GrowLog.query.filter_by(id=log_id, plant_id=plant.id).first_or_404()
form = GrowLogForm(obj=log)
# Lock the dropdown to this one plant
form.plant_uuid.choices = [(plant.uuid, plant.common_name.name)]
form.plant_uuid.data = plant.uuid
if form.validate_on_submit():
log.event_type = form.event_type.data
log.title = form.title.data
log.notes = form.notes.data
log.is_public = form.is_public.data
db.session.commit()
flash('Grow log updated.', 'success')
return redirect(url_for('growlog.list_logs', plant_uuid=plant_uuid))
return render_template(
'growlog/log_form.html',
form=form,
plant_uuid=plant_uuid,
plant=plant,
log=log
)
@bp.route('/<uuid:plant_uuid>/delete/<int:log_id>', methods=['POST'])
@login_required
def delete_log(plant_uuid, log_id):
plant = _get_plant_by_uuid(plant_uuid)
log = GrowLog.query.filter_by(id=log_id, plant_id=plant.id).first_or_404()
db.session.delete(log)
db.session.commit()
flash('Grow log deleted.', 'warning')
return redirect(url_for('growlog.list_logs', plant_uuid=plant_uuid))

View File

@ -1,10 +1,41 @@
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block title %}Add Grow Log{% endblock %}
{% block content %} {% block content %}
<h2>Add Log for Plant #{{ plant.id }}</h2> <h2>Add Grow Log{% if plant %} for {{ plant.common_name.name }}{% endif %}</h2>
<form method="POST"> <form method="POST">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p>{{ form.event_type.label }}<br>{{ form.event_type() }}</p>
<p>{{ form.note.label }}<br>{{ form.note(rows=4) }}</p> {# only show this when not pre-selecting via URL #}
<p>{{ form.submit() }}</p> {% if not hide_plant_select %}
<div class="mb-3">
{{ form.plant_uuid.label(class="form-label") }}
{{ form.plant_uuid(class="form-select") }}
</div>
{% else %}
{{ form.plant_uuid(type="hidden") }}
{% endif %}
<div class="mb-3">
{{ form.event_type.label(class="form-label") }}
{{ form.event_type(class="form-select") }}
</div>
<div class="mb-3">
{{ form.title.label(class="form-label") }}
{{ form.title(class="form-control") }}
</div>
<div class="mb-3">
{{ form.notes.label(class="form-label") }}
{{ form.notes(class="form-control", rows=4) }}
</div>
<div class="mb-3 form-check">
{{ form.is_public(class="form-check-input") }}
{{ form.is_public.label(class="form-check-label") }}
</div>
<button type="submit" class="btn btn-primary">{{ form.submit.label.text }}</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -1,36 +1,104 @@
{# plugins/growlog/templates/growlog/log_list.html #} {# plugins/growlog/templates/growlog/log_list.html #}
{% import 'core_ui/_media_macros.html' as media %}
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block content %} {% block title %}
<h2>Logs for Plant #{{ plant.id }}</h2> {% if plant %}
<a href="{{ url_for('growlog.add_log', plant_id=plant.id) }}">Add New Log</a> Logs for {{ plant.common_name.name }}
{% else %}
<ul> Recent Grow Logs
{% for log in logs %} {% endif %}
<li class="mb-3"> {% endblock %}
<strong>{{ log.timestamp.strftime('%Y-%m-%d') }}:</strong>
{{ log.event_type }} {{ log.note }} {% block content %}
{% if log.media_items %} <div class="d-flex justify-content-between align-items-center mb-4">
<br><em>Images:</em> <h2 class="mb-0">
<ul class="list-unstyled ps-3"> {% if plant %}
{% for image in log.media_items %} Grow Logs for {{ plant.common_name.name }}
<li class="mb-2"> {% else %}
<img Recent Grow Logs
src="{{ generate_image_url(image) }}" {% endif %}
width="150" </h2>
class="img-thumbnail" {# “Add” button: carry plant_uuid when in single-plant view #}
alt="Log image" <a
><br> href="{% if plant %}{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}{% else %}{{ url_for('growlog.add_log') }}{% endif %}"
{{ image.caption or "No caption" }} class="btn btn-success">
</li> <i class="bi bi-plus-lg"></i> Add Log
{% endfor %} </a>
</ul> </div>
{% if logs %}
<div class="list-group">
{% for log in logs %}
<div class="list-group-item mb-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="mb-1">{{ log.title or 'Untitled' }}</h5>
<small class="text-muted">
{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</div>
{% if not plant %}
{# Show which plant this log belongs to when listing across all plants #}
<div class="ms-auto text-end">
<small class="text-secondary">Plant:</small><br>
<a href="{{ url_for('growlog.list_logs', plant_uuid=log.plant.uuid) }}">
{{ log.plant.common_name.name }}
</a>
</div>
{% endif %}
</div>
<p class="mt-2 mb-1">{{ log.notes or '—' }}</p>
<span class="badge {% if log.is_public %}bg-info text-dark{% else %}bg-secondary{% endif %}">
{% if log.is_public %}Public{% else %}Private{% endif %}
</span>
{% if log.media_items.count() %}
<div class="mt-3 d-flex flex-wrap gap-2">
{% for media in log.media_items %}
<img
src="{{ generate_image_url(media) }}"
class="img-thumbnail"
style="max-width:100px;"
alt="{{ media.caption or '' }}"
>
{% endfor %}
</div>
{% endif %}
<div class="mt-3">
<a
href="{{ url_for(
'growlog.edit_log',
plant_uuid=plant.uuid if plant else log.plant.uuid,
log_id=log.id
) }}"
class="btn btn-sm btn-outline-primary me-2">
Edit
</a>
<form
method="POST"
action="{{ url_for(
'growlog.delete_log',
plant_uuid=plant.uuid if plant else log.plant.uuid,
log_id=log.id
) }}"
class="d-inline"
onsubmit="return confirm('Delete this log?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">
No grow logs found{% if plant %} for {{ plant.common_name.name }}{% endif %}.
<a href="{% if plant %}{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}{% else %}{{ url_for('growlog.add_log') }}{% endif %}">
Add one now
</a>.
</p>
{% endif %} {% endif %}
</li>
{% endfor %}
</ul>
{# Use shared macro for any remaining media lists #}
{{ media.render_media_list(logs|map(attribute='media_items')|sum, thumb_width=150, current_user=current_user) }}
{% endblock %} {% endblock %}

View File

@ -40,44 +40,74 @@ def inject_image_helper():
@bp.route('/', methods=['GET']) @bp.route('/', methods=['GET'])
@login_required @login_required
def index(): def index():
plants = ( # ── 1) Read query-params ───────────────────────────────────────────
page = request.args.get('page', 1, type=int)
per_page = request.args.get(
'per_page',
current_app.config.get('PLANTS_PER_PAGE', 12),
type=int
)
view_mode = request.args.get('view', 'grid', type=str) # 'grid' or 'list'
q = request.args.get('q', '', type=str).strip()
type_filter= request.args.get('type', '', type=str).strip().lower()
# ── 2) Build base SQLAlchemy query ────────────────────────────────
qry = (
Plant.query Plant.query
.options(joinedload(Plant.media_items)) .options(joinedload(Plant.media_items))
.filter_by(owner_id=current_user.id) .filter_by(owner_id=current_user.id)
.order_by(Plant.id.desc())
.all()
) )
user_plants_count = Plant.query.filter_by(owner_id=current_user.id).count() # ── 3) Optional name search ───────────────────────────────────────
user_images_count = Media.query.filter_by(uploader_id=current_user.id).count() if q:
total_plants_count = Plant.query.count() qry = qry.join(PlantCommonName).filter(
total_images_count = Media.query.count() PlantCommonName.name.ilike(f'%{q}%')
)
# ── 4) Optional type filter ───────────────────────────────────────
if type_filter:
qry = qry.filter(Plant.plant_type.ilike(type_filter))
# ── 5) Apply ordering + paginate ─────────────────────────────────
pagination = (
qry.order_by(Plant.id.desc())
.paginate(page=page, per_page=per_page, error_out=False)
)
plants = pagination.items
# ── 6) Gather stats and distinct types as before ─────────────────
stats = {
'user_plants': Plant.query.filter_by(owner_id=current_user.id).count(),
'user_images': Media.query.filter_by(uploader_id=current_user.id).count(),
'total_plants': Plant.query.count(),
'total_images': Media.query.count(),
}
plant_types = [ plant_types = [
pt[0] row[0]
for pt in ( for row in (
db.session.query(Plant.plant_type) db.session
.query(Plant.plant_type)
.filter_by(owner_id=current_user.id) .filter_by(owner_id=current_user.id)
.distinct() .distinct()
.all() .all()
) )
] ]
stats = { # ── 7) Render, passing both pagination AND per-page items ─────────
'user_plants': user_plants_count,
'user_images': user_images_count,
'total_plants': total_plants_count,
'total_images': total_images_count,
}
return render_template( return render_template(
'plant/index.html', 'plant/index.html',
plants = plants, plants = plants,
pagination = pagination,
view_mode = view_mode,
q = q,
type_filter = type_filter,
per_page = per_page,
plant_types = plant_types, plant_types = plant_types,
stats=stats, stats = stats
) )
@bp.route('/', methods=['GET', 'POST']) @bp.route('/create', methods=['GET', 'POST'])
@login_required @login_required
def create(): def create():
form = PlantForm() form = PlantForm()

View File

@ -14,7 +14,114 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
<!-- Stats container (desktop only, collapsed by default) --> <h2 class="mb-4">View Entries</h2>
{# ── Import / Export, Stats, Filters & View Toggle ─────────────────────── #}
<div class="mb-3 d-flex flex-wrap justify-content-between align-items-center">
<!-- Left: import/export & stats toggle -->
<div class="d-flex align-items-center mb-2">
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#importModal">
Import CSV
</button>
<a href="{{ url_for('utility.export_data') }}" class="btn btn-secondary me-2">
Export My Data
</a>
<button
class="btn btn-secondary me-2 d-inline-block d-md-none"
data-bs-toggle="modal"
data-bs-target="#statsModal">
Stats
</button>
<button
class="btn btn-secondary me-2 d-none d-md-inline-block"
data-bs-toggle="collapse"
data-bs-target="#statsBox"
aria-expanded="false"
aria-controls="statsBox"
id="statsToggle">
Stats <i class="bi bi-chevron-down"></i>
</button>
</div>
<!-- Right: filter form + view toggle -->
<form
method="get"
action="{{ url_for('plant.index') }}"
class="d-flex flex-wrap align-items-center mb-2"
>
<div class="input-group me-2" style="min-width:200px;">
<span class="input-group-text">Search</span>
<input
type="search"
name="q"
value="{{ q }}"
class="form-control"
placeholder="by name…"
/>
</div>
<select
name="type"
class="form-select me-2"
style="min-width:140px;"
onchange="this.form.submit()"
>
<option value="">All Types</option>
{% for t in plant_types %}
<option
value="{{ t|lower }}"
{% if t|lower == type_filter %}selected{% endif %}
>{{ t }}</option>
{% endfor %}
</select>
<select
name="per_page"
class="form-select me-2"
style="min-width:140px;"
onchange="this.form.submit()"
>
{% for size in [6,12,18,24] %}
<option value="{{ size }}" {% if per_page == size %}selected{% endif %}>
{{ size }} per page
</option>
{% endfor %}
</select>
{# keep the current view so Apply doesnt reset it #}
<input type="hidden" name="view" value="{{ view_mode }}" />
<button type="submit" class="btn btn-primary me-2">Apply</button>
<div class="btn-group" role="group" aria-label="View mode">
<a
href="{{ url_for('plant.index',
page=pagination.page,
per_page=per_page,
q=q,
type=type_filter,
view='grid'
) }}"
class="btn btn-outline-secondary {% if view_mode=='grid' %}active{% endif %}"
title="Card View"
><i class="bi bi-grid-3x3-gap-fill"></i></a>
<a
href="{{ url_for('plant.index',
page=pagination.page,
per_page=per_page,
q=q,
type=type_filter,
view='list'
) }}"
class="btn btn-outline-secondary {% if view_mode=='list' %}active{% endif %}"
title="List View"
><i class="bi bi-list-ul"></i></a>
</div>
</form>
</div>
<!-- Stats container (desktop only) -->
<div class="collapse mb-3" id="statsBox"> <div class="collapse mb-3" id="statsBox">
<div class="d-none d-md-block p-3 bg-light border rounded"> <div class="d-none d-md-block p-3 bg-light border rounded">
<h5 class="text-center">Statistics</h5> <h5 class="text-center">Statistics</h5>
@ -95,214 +202,168 @@
</div> </div>
</div> </div>
<h2>View Entries</h2> {# ── Results (list vs grid) ──────────────────────────────────────────── #}
{% if view_mode=='list' %}
<!-- Import / Export & Filter bar --> <div class="list-group">
<div class="mb-3 d-flex flex-wrap justify-content-between align-items-center">
<div class="mb-2 d-flex align-items-center">
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#importModal">
Import CSV
</button>
<a href="{{ url_for('utility.export_data') }}" class="btn btn-secondary me-2">Export My Data</a>
<button
class="btn btn-secondary me-2 d-inline-block d-md-none"
data-bs-toggle="modal"
data-bs-target="#statsModal">
Stats
</button>
<button
class="btn btn-secondary me-2 d-none d-md-inline-block"
data-bs-toggle="collapse"
data-bs-target="#statsBox"
aria-expanded="false"
aria-controls="statsBox"
id="statsToggle">
Stats <i class="bi bi-chevron-down"></i>
</button>
</div>
<div class="d-flex flex-wrap flex-md-nowrap align-items-center">
<div class="input-group me-2 mb-2 mb-md-0" style="min-width:200px;">
<span class="input-group-text">Search</span>
<input id="searchInput" type="text" class="form-control" placeholder="by name…">
</div>
<select id="typeFilter" class="form-select me-2 mb-2 mb-md-0" style="min-width:140px;">
<option value="">All Types</option>
{% for t in plant_types %}
<option value="{{ t|lower }}">{{ t }}</option>
{% endfor %}
</select>
<select id="pageSizeSelect" class="form-select me-2 mb-2 mb-md-0" style="min-width:140px;">
{% for size in [6,12,18,24] %}
<option value="{{ size }}" {% if size == 12 %}selected{% endif %}>
{{ size }} per page
</option>
{% endfor %}
</select>
</div>
</div>
<!-- Plant cards -->
<div class="row row-cols-1 row-cols-lg-3 g-3" id="plantContainer">
{% for plant in plants %} {% for plant in plants %}
<div class="col plant-card" <div class="list-group-item py-3">
data-name="{{ plant.common_name.name|lower }}" <div class="row align-items-center">
data-type="{{ plant.plant_type|lower }}"> <div class="col-auto">
<div class="card h-100"> {%- set f = (plant.media_items|selectattr('featured')|first)
{# Determine featured image: first any marked featured, else first media #} or (plant.media_items|first) -%}
{% set featured = plant.media|selectattr('featured')|first %}
{% if not featured and plant.media %}
{% set featured = plant.media[0] %}
{% endif %}
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"> <a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
<img <img
src="{{ generate_image_url(featured) }}" src="{{ generate_image_url(f) }}"
class="card-img-top" class="img-thumbnail"
style="height:200px;object-fit:cover;" style="width:80px;height:80px;object-fit:cover;"
alt="Image for {{ plant.common_name.name }}"> alt="Image for {{ plant.common_name.name }}"
>
</a> </a>
</div>
<div class="card-body d-flex flex-column"> <div class="col">
<h5 class="card-title"> <h5 class="mb-1">
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"> <a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
{{ plant.common_name.name }} {{ plant.common_name.name }}
</a> </a>
</h5> </h5>
<h6 class="text-muted">{{ plant.uuid }}</h6> <small class="text-muted">{{ plant.uuid }}</small>
<p class="mb-1"><strong>Type:</strong> {{ plant.plant_type }}</p> <div class="mt-1">
<p class="mb-1"><strong>Scientific Name:</strong> {{ plant.scientific_name.name }}</p> <span class="badge bg-secondary">{{ plant.plant_type }}</span>
{% if plant.mother_uuid %} </div>
<p class="mb-1"> </div>
<strong>Mother:</strong> <div class="col-auto text-nowrap">
<a href="{{ url_for('plant.detail', uuid_val=plant.mother_uuid) }}"> <a
{{ plant.mother_uuid }} href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"
class="btn btn-sm btn-primary me-1"
>View</a>
<a
href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}"
class="btn btn-sm btn-secondary"
>Edit</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for plant in plants %}
<div class="col">
<div class="card h-100">
{%- set f = (plant.media_items|selectattr('featured')|first)
or (plant.media_items|first) -%}
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
<img
src="{{ generate_image_url(f) }}"
class="card-img-top"
style="height:200px;object-fit:cover;"
alt="Image for {{ plant.common_name.name }}"
>
</a> </a>
</p> <div class="card-body d-flex flex-column">
{% endif %} <h5 class="card-title mb-1">
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
{{ plant.common_name.name }}
</a>
</h5>
<small class="text-muted mb-2">{{ plant.uuid }}</small>
<div class="mt-auto"> <div class="mt-auto">
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}" <a
class="btn btn-sm btn-primary me-1">View</a> href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"
<a href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}" class="btn btn-sm btn-primary me-1"
class="btn btn-sm btn-secondary me-1">Edit</a> >View</a>
<a href="{{ url_for('utility.download_qr', uuid_val=plant.uuid) }}" <a
class="btn btn-sm btn-outline-primary me-1">Direct QR</a> href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}"
<a href="{{ url_for('utility.download_qr_card', uuid_val=plant.uuid) }}" class="btn btn-sm btn-secondary"
class="btn btn-sm btn-outline-secondary">Card QR</a> >Edit</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
<!-- pagination controls --> {# ── Pagination ─────────────────────────────────────────────────────── #}
<nav aria-label="Page navigation" class="mt-4 mb-5"> <nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center" id="pagination"></ul> <ul class="pagination justify-content-center">
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
<a
class="page-link"
href="{{ url_for('plant.index',
page=1,
per_page=per_page,
q=q,
type=type_filter,
view=view_mode
) }}"
>« First</a>
</li>
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
<a
class="page-link"
href="{{ url_for('plant.index',
page=pagination.prev_num or 1,
per_page=per_page,
q=q,
type=type_filter,
view=view_mode
) }}"
> Prev</a>
</li>
{% set total = pagination.pages %}
{% set curr = pagination.page %}
{% set w = 3 %}
{% for p in range(1, total+1) %}
{% if p <= w or p > total-w or (p >= curr-1 and p <= curr+1) %}
<li class="page-item {% if p==curr %}active{% endif %}">
<a
class="page-link"
href="{{ url_for('plant.index',
page=p,
per_page=per_page,
q=q,
type=type_filter,
view=view_mode
) }}"
>{{ p }}</a>
</li>
{% elif p == w+1 or p == total-w %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
<a
class="page-link"
href="{{ url_for('plant.index',
page=pagination.next_num or total,
per_page=per_page,
q=q,
type=type_filter,
view=view_mode
) }}"
>Next </a>
</li>
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
<a
class="page-link"
href="{{ url_for('plant.index',
page=total,
per_page=per_page,
q=q,
type=type_filter,
view=view_mode
) }}"
>Last »</a>
</li>
</ul>
</nav> </nav>
{% endblock %}
<!-- client-side filtering & pagination script --> {% block scripts %}
<script> {{ super() }}
(function() {
const searchInput = document.getElementById('searchInput');
const typeFilter = document.getElementById('typeFilter');
const pageSizeSelect = document.getElementById('pageSizeSelect');
const container = document.getElementById('plantContainer');
const pagination = document.getElementById('pagination');
const cards = Array.from(container.querySelectorAll('.plant-card'));
let currentPage = 1;
let pageSize = parseInt(pageSizeSelect.value, 10);
function filterAndPaginate() {
const q = searchInput.value.trim().toLowerCase();
const type = typeFilter.value;
const filtered = cards.filter(card => {
return card.dataset.name.includes(q) &&
(!type || card.dataset.type === type);
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
if (currentPage > totalPages) currentPage = totalPages;
cards.forEach(c => c.style.display = 'none');
const start = (currentPage - 1) * pageSize;
filtered.slice(start, start + pageSize)
.forEach(c => c.style.display = '');
pagination.innerHTML = '';
// Prev button
const prevLi = document.createElement('li');
prevLi.className = 'page-item' + (currentPage === 1 ? ' disabled' : '');
prevLi.innerHTML = `<a class="page-link" href="#">Prev</a>`;
prevLi.onclick = e => {
e.preventDefault();
if (currentPage > 1) { currentPage--; filterAndPaginate(); }
};
pagination.appendChild(prevLi);
// Page numbers
if (totalPages <= 5) {
for (let i = 1; i <= totalPages; i++) {
const li = document.createElement('li');
li.className = 'page-item' + (i === currentPage ? ' active' : '');
li.innerHTML = `<a class="page-link" href="#">${i}</a>`;
li.onclick = e => {
e.preventDefault();
currentPage = i;
filterAndPaginate();
};
pagination.appendChild(li);
}
} else {
[1,2,3].forEach(n => {
const li = document.createElement('li');
li.className = 'page-item' + (n === currentPage ? ' active' : '');
li.innerHTML = `<a class="page-link" href="#">${n}</a>`;
li.onclick = e => {
e.preventDefault();
currentPage = n;
filterAndPaginate();
};
pagination.appendChild(li);
});
const ell = document.createElement('li');
ell.className = 'page-item disabled';
ell.innerHTML = `<span class="page-link">…</span>`;
pagination.appendChild(ell);
const lastLi = document.createElement('li');
lastLi.className = 'page-item' + (totalPages === currentPage ? ' active' : '');
lastLi.innerHTML = `<a class="page-link" href="#">${totalPages}</a>`;
lastLi.onclick = e => {
e.preventDefault();
currentPage = totalPages;
filterAndPaginate();
};
pagination.appendChild(lastLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = 'page-item' + (currentPage === totalPages ? ' disabled' : '');
nextLi.innerHTML = `<a class="page-link" href="#">Next</a>`;
nextLi.onclick = e => {
e.preventDefault();
if (currentPage < totalPages) { currentPage++; filterAndPaginate(); }
};
pagination.appendChild(nextLi);
}
// Initialize and bind events
filterAndPaginate();
searchInput.addEventListener('input', () => { currentPage = 1; filterAndPaginate(); });
typeFilter.addEventListener('change', () => { currentPage = 1; filterAndPaginate(); });
pageSizeSelect.addEventListener('change',() => {
pageSize = parseInt(pageSizeSelect.value, 10);
currentPage = 1; filterAndPaginate();
});
})();
</script>
<script> <script>
// Toggle chevron icon on desktop collapse // Toggle chevron icon on desktop collapse
const statsBox = document.getElementById('statsBox'); const statsBox = document.getElementById('statsBox');