diff --git a/README.md b/README.md index eeb9bfb..bcbc759 100644 --- a/README.md +++ b/README.md @@ -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 -- 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 -- Add logs with images, notes, and growth metrics -- Track success/failure events, mutation events, pest/disease sightings -- Link logs to substrate recipes and fertilizers +* **Create/Edit** plants with: + + * UUID & custom slug + * 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 -- Lineage links are created by the new owner -- Parent plant's owner must **approve** the linkage -- Verified lineage is marked with a badge and shown in plant lineage tree -- Pending links are visible only to the creator + +* **Neo4j** graph for parentβ†’child relationships. +* Pending link requests: parent-owner approval required. +* Verified links get a badge; unverified shown in draft. ### πŸ’° 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 -- Track custom mixes by ingredient (e.g., β€œFine Pumice”, β€œLarge Bark”) -- Store cost per ingredient and auto-calculate total mix cost -- Recipes can be reused across plants -- Fertilizer schedules can be attached to logs and outcomes tracked +* **Owner & admin** see full pricing history; others do not. +* On transfer, seller’s last price is retained but hidden; buyer sets new price. +* **Price records** tracked as `PriceHistory` entries. +* Visibility toggles: `public`, `unlisted`, `folder-only`. + +### πŸ§ͺ 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 -- When a plant is sold, sellers can add: - - Carrier, tracking number, est. delivery date -- Ownership updates post buyer confirmation -- Shipping logs are attached to the plant transfer log + +* During sale, seller enters: carrier, tracking number, est. delivery. +* Transfer completes after buyer confirmation. +* Shipping events attached to ownership log. ### πŸ“ 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}` -- 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 -- **User** – default -- **Moderator** – can manage flags, notes -- **Admin** – full backend, plugin, pricing, banning control -- Roles are extensible via admin UI - -### πŸ›‘ Moderator Panel -- View and resolve reports -- Add private notes to users or plants (e.g., warnings, suspicion) -- Ban users - - Banned users' plants become read-only - - Cannot add new plants - - 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 +| Plugin | Purpose | Status | +| --------------- | --------------------------------------------- | ---------- | +| **plant** | Core plant CRUD & lineage | βœ… Live | +| **media** | Image storage, EXIF, voting, featured | βœ… Live | +| **utility** | Import/Export CSV/ZIP, QR code generation | βœ… Live | +| **growlog** | Growth logs & update images | βœ… Live | +| **submissions** | Community submissions & voting | βœ… Live | +| **vendor** | Vendor/collective profiles, membership, roles | βœ… Live | +| **materials** | Potting mixes, proprietary flags | πŸ”œ Planned | +| **ledger** | Cost logs, fees, tax, shipping | πŸ”œ Planned | +| **inventory** | Stock management, restock/depletion | πŸ”œ Planned | +| **collective** | Shared groups with RBAC | πŸ”œ Planned | --- -## 🏷️ Labeling System: QR + Barcode +## πŸ“Š Data Models & Schema -### QR Code -- Generated on server after initial sync -- Unique to plant, never changes -- SVG and PNG available +Refer to `/migrations/` for full schema. Key tables include: -### Barcode Fallback -- CODE-128 format -- If data too long, split into stacked barcodes -- Same encoded info: plant ID, owner ID, visibility, timestamp -- Printable as label from dashboard +* `users`, `roles`, `user_roles` +* `plant_common_name`, `plant_scientific_name` +* **`plant`**: FKs β†’ common, scientific, owner, mother\_uuid, featured\_media +* `media`: FKs β†’ plant\_id, growlog\_id, uploader +* `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 | -|--------------------------|-------|---------------|-----------|--------| -| View Logs | βœ… | βœ… (if shared) | βœ… | βœ… | -| Edit Logs | βœ… | πŸ”* | 🚫 | βœ… | -| View Pricing | βœ… | βœ… (opt-in) | 🚫 | βœ… | -| Ban User / Flag Review | 🚫 | 🚫 | βœ… | βœ… | -| Approve Lineage | 🚫 | 🚫 | 🚫 | βœ… | -| Confirm Lineage | βœ… | 🚫 | 🚫 | βœ… | +* **REST**: `/api/v1/` CRUD, OpenAPI docs, JWT auth, rate limiting +* **GraphQL**: `/graphql` batched queries for plants + relations +* **CLI**: -πŸ”* if granted by group manager + * `flask db {init,migrate,upgrade}` + * `flask user create` + * `flask plugin {install,list,enable}` + * `flask preload-data` --- -## 🌍 Offline Sync (PWA) -- Full add/log/edit possible offline -- Sync queue uploads on connection -- QR/barcodes generated **after** server confirms sync -- Client-side validation before queue +## 🌐 User Interface & PWA + +* **Bootstrap 5** responsive layouts +* **Desktop**: large gallery + thumbnail carousel +* **Mobile**: grid lightbox with pinch-zoom & close button +* Offline caching via service worker --- -## 🧠 Smart Tools +## πŸ”’ Security & Privacy -### ⭐️ User Reputation System -- Users rated after trades (accuracy, responsiveness, helpfulness) -- β€œTrusted Grower” tag auto-assigned above threshold -- Can be revoked via vote or admin action - -### 🌿 Inter-Plant Comparison -- Timeline comparison for growth, size, or log outcomes -- Side-by-side charts and event overlays +* **End-to-End Encryption** for notes, logs, pricing +* **Key Escrow** (opt-in) vs **Maximum Privacy** (no escrow) +* **Blind Transfers**: admin can reassign without decryption +* **Audit Logs**: immutable record of all actions +* **Role-Based Access**: owner, group member, moderator, admin --- -## πŸ”§ Admin & Dev Tools +## πŸ›‚ Permissions Matrix -### πŸ“€ Seed Data Generator -- Seeds with common aroids, herbs, and test users -- Covers full range of roles, plant types, and edge cases +| Action | Owner | Group | Mod | Admin | +| ----------------------------- | :---: | :---: | :-: | :---: | +| Create/Edit own plants & logs | βœ… | βœ…\* | βœ… | βœ… | +| View public plants/logs | βœ… | βœ… | βœ… | βœ… | +| Approve lineage & submissions | ❌ | ❌ | βœ… | βœ… | +| Manage vendors/collectives | ❌ | ❌ | ❌ | βœ… | +| Transfer override (no-owner) | ❌ | ❌ | ❌ | βœ… | +| Plugin management & settings | ❌ | ❌ | ❌ | βœ… | -### πŸ—‚ Plugin System -- 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 +\* if granted by group manager --- -## πŸ›£ API System +## βš™ Admin & Dev Tools -### REST & GraphQL APIs -- REST: OpenAPI-documented endpoints -- GraphQL: Advanced multi-entity queries -- JWT-secured -- Follows role-based access rules +* **Moderator Panel**: flag review, user notes, bans +* **Seed Data Generator**: sample users, plants, logs +* **Plugin CLI**: install/enable/disable plugins at runtime +* **ERD Viewer**: visualize database schema +* **Disaster Recovery**: SQL dump + file archive + JSON profiles --- -## 🌐 Internationalization -- Flask-Babel integration -- Language switcher in UI -- Community-managed translation interface (admin toggled) +## 🌍 Internationalization + +* **Flask-Babel** for translations +* Language switcher in UI +* Community-managed translation portal (admin toggle) --- -## πŸ“… Future Enhancements +## πŸ›£ Roadmap & Future Enhancements -- 🧠 AI Journal Assistant (log suggestions, summarization) -- πŸ“† Calendar View for logs/reminders -- 🧰 Visual ERD Generator Tool -- πŸ›’ Live Auctions Plugin (for curated resale events) +* AI-powered journal assistant & mutation detection +* Calendar reminders for watering, fertilizing +* Visual ERD generator tool in admin UI +* Live auction & escrow plugin +* Third-party webhook integrations (Zapier, Discord) --- diff --git a/Security.md b/Security.md new file mode 100644 index 0000000..0f338b2 --- /dev/null +++ b/Security.md @@ -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 I’m 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 can’t 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 }}._ diff --git a/app/errors.py b/app/errors.py index 177089e..06d4677 100644 --- a/app/errors.py +++ b/app/errors.py @@ -4,7 +4,7 @@ bp = Blueprint('errors', __name__) @bp.app_errorhandler(400) def bad_request(error): - return render_template('400.html'), 400 + return render_template('400.html', error=error), 400 @bp.app_errorhandler(404) def bad_request(error): diff --git a/entrypoint.sh b/entrypoint.sh index a1878fb..82f0f2b 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -24,9 +24,7 @@ fi # Autogenerate new migration if needed echo "[πŸ› οΈ] Checking for new schema changes" -if ! flask db migrate -m "auto-migrate" --compare-type --render-as-batch; then - echo "[ℹ️] No schema changes detected" -fi +flask db migrate -m "auto-migrate" || echo "[ℹ️] No schema changes detected" # Apply migrations echo "[▢️] Applying database migrations" diff --git a/migrations/env.py b/migrations/env.py index f47ac5c..12671da 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -53,7 +53,7 @@ def run_migrations_online(): target_metadata=target_metadata, compare_type=True, sort_tables=True, - render_as_batch=True, # βœ… important! + render_as_batch=True, ) with context.begin_transaction(): context.run_migrations() diff --git a/migrations/versions/209596f02c2a_auto_migrate.py b/migrations/versions/209596f02c2a_auto_migrate.py new file mode 100644 index 0000000..eca65e2 --- /dev/null +++ b/migrations/versions/209596f02c2a_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/42ce181f4eab_auto_migrate.py b/migrations/versions/42ce181f4eab_auto_migrate.py new file mode 100644 index 0000000..d043d9c --- /dev/null +++ b/migrations/versions/42ce181f4eab_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/93f8a5cbc643_auto_migrate.py b/migrations/versions/93f8a5cbc643_auto_migrate.py new file mode 100644 index 0000000..4ba265e --- /dev/null +++ b/migrations/versions/93f8a5cbc643_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/9cc2626a6e79_auto_migrate.py b/migrations/versions/9cc2626a6e79_auto_migrate.py new file mode 100644 index 0000000..b456be3 --- /dev/null +++ b/migrations/versions/9cc2626a6e79_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/d54a88422a68_auto_migrate.py b/migrations/versions/d54a88422a68_auto_migrate.py new file mode 100644 index 0000000..fb6ac3f --- /dev/null +++ b/migrations/versions/d54a88422a68_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/d7bbffbbc931_auto_migrate.py b/migrations/versions/d7bbffbbc931_auto_migrate.py new file mode 100644 index 0000000..ead1201 --- /dev/null +++ b/migrations/versions/d7bbffbbc931_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/f00a9585a348_auto_migrate.py b/migrations/versions/f00a9585a348_auto_migrate.py new file mode 100644 index 0000000..5fd857f --- /dev/null +++ b/migrations/versions/f00a9585a348_auto_migrate.py @@ -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 ### diff --git a/nip.zip b/nip.zip index 44a807a..54feb40 100644 Binary files a/nip.zip and b/nip.zip differ diff --git a/plugins/core_ui/templates/core_ui/base.html b/plugins/core_ui/templates/core_ui/base.html index f4b691f..29d4fec 100644 --- a/plugins/core_ui/templates/core_ui/base.html +++ b/plugins/core_ui/templates/core_ui/base.html @@ -1,100 +1,218 @@ - - - - {% block title %}Nature In Pots Community{% endblock %} - - + + + {% block title %}Nature In Pots Community{% endblock %} + + + - + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} {% endwith %} {% block content %}{% endblock %} -
- + + diff --git a/plugins/core_ui/templates/core_ui/home.html b/plugins/core_ui/templates/core_ui/home.html index b68933c..9fcb38a 100644 --- a/plugins/core_ui/templates/core_ui/home.html +++ b/plugins/core_ui/templates/core_ui/home.html @@ -1,6 +1,55 @@ {% extends 'core_ui/base.html' %} {% block title %}Home | Nature In Pots{% endblock %} + {% block content %} -

Welcome to Nature In Pots 🌿

-

This is the community hub for plant tracking, propagation history, and price sharing.

+ +
+

Welcome to Nature In Pots

+

+ Your internal platform for comprehensive plant tracking, propagation history, and collaborative logging. +

+

+ Free to use for the time being
+ (A future subscription or licensing model may be introduced as needed.) +

+
+ + +
+
+

Plant Profiles

+

Quickly create and manage detailed recordsβ€”type, names, lineage, notes, and custom slugs for easy sharing.

+
+
+

Grow Logs

+

Maintain a timeline of growth metrics, health events, substrate mixes, and propagation notes.

+
+
+

Image Gallery

+

Upload, rotate, and feature photos for each plant. Community voting and one-click β€œfeatured” selection.

+
+
+

Lineage Tracking

+

Visualize parent–child relationships with a graph powered by Neo4jβ€”track every cutting, seed, and division.

+
+
+

Pricing & Transfers

+

Securely log acquisition costs, resale prices, and ownership changesβ€”with history retained and protected.

+
+
+

Import & Export

+

Bulk-import plants and media via CSV/ZIP. Export your entire dataset and images for backups or reporting.

+
+
+ + +
+ {% if current_user.is_authenticated %} + View My Plants + Import Data + {% else %} + Register Now + Log In + {% endif %} +
{% endblock %} diff --git a/plugins/growlog/forms.py b/plugins/growlog/forms.py index ddf1bd7..01b3f7c 100644 --- a/plugins/growlog/forms.py +++ b/plugins/growlog/forms.py @@ -1,8 +1,16 @@ +# plugins/growlog/forms.py + 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 class GrowLogForm(FlaskForm): + plant_uuid = SelectField( + 'Plant', + choices=[], # injected in view + validators=[DataRequired()] + ) + event_type = SelectField('Event Type', choices=[ ('water', 'Watered'), ('fertilizer', 'Fertilized'), @@ -11,5 +19,7 @@ class GrowLogForm(FlaskForm): ('pest', 'Pest Observed') ], validators=[DataRequired()]) - note = TextAreaField('Notes', validators=[Length(max=1000)]) - submit = SubmitField('Add Log') \ No newline at end of file + title = StringField('Title', validators=[Length(max=255)]) + notes = TextAreaField('Notes', validators=[Length(max=1000)]) + is_public = BooleanField('Public?') + submit = SubmitField('Add Log') diff --git a/plugins/growlog/models.py b/plugins/growlog/models.py index e922bec..7b2bc8f 100644 --- a/plugins/growlog/models.py +++ b/plugins/growlog/models.py @@ -7,6 +7,7 @@ class GrowLog(db.Model): id = db.Column(db.Integer, primary_key=True) 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) notes = db.Column(db.Text, nullable=True) is_public = db.Column(db.Boolean, default=False, nullable=False) diff --git a/plugins/growlog/routes.py b/plugins/growlog/routes.py index 5987941..c3b7fdb 100644 --- a/plugins/growlog/routes.py +++ b/plugins/growlog/routes.py @@ -1,31 +1,176 @@ -from flask import Blueprint, render_template, redirect, url_for, request -from flask_login import login_required +from uuid import UUID as _UUID +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 .models import GrowLog 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//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 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 common‐name 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('//add', methods=['GET','POST']) @login_required -def view_logs(plant_id): - 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//logs/add', methods=['GET', 'POST']) -@login_required -def add_log(plant_id): - plant = Plant.query.get_or_404(plant_id) +def add_log(plant_uuid=None): 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(): + # 3) on POST, resolve via form.plant_uuid + plant = _get_plant_by_uuid(form.plant_uuid.data) log = GrowLog( - plant_id=plant.id, - event_type=form.event_type.data, - note=form.note.data + plant_id = plant.id, + event_type = form.event_type.data, + title = form.title.data, + notes = form.notes.data, + is_public = form.is_public.data, ) db.session.add(log) db.session.commit() - return redirect(url_for('growlog.view_logs', plant_id=plant.id)) - return render_template('growlog/log_form.html', form=form, plant=plant) \ No newline at end of file + flash('Grow log added.', 'success') + 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('/') +@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('//edit/', 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('//delete/', 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)) diff --git a/plugins/growlog/templates/growlog/log_form.html b/plugins/growlog/templates/growlog/log_form.html index 235dfa4..ecef5f9 100644 --- a/plugins/growlog/templates/growlog/log_form.html +++ b/plugins/growlog/templates/growlog/log_form.html @@ -1,10 +1,41 @@ {% extends 'core_ui/base.html' %} +{% block title %}Add Grow Log{% endblock %} + {% block content %} -

Add Log for Plant #{{ plant.id }}

-
- {{ form.hidden_tag() }} -

{{ form.event_type.label }}
{{ form.event_type() }}

-

{{ form.note.label }}
{{ form.note(rows=4) }}

-

{{ form.submit() }}

-
-{% endblock %} \ No newline at end of file +

Add Grow Log{% if plant %} for {{ plant.common_name.name }}{% endif %}

+
+ {{ form.hidden_tag() }} + + {# only show this when not pre-selecting via URL #} + {% if not hide_plant_select %} +
+ {{ form.plant_uuid.label(class="form-label") }} + {{ form.plant_uuid(class="form-select") }} +
+ {% else %} + {{ form.plant_uuid(type="hidden") }} + {% endif %} + +
+ {{ form.event_type.label(class="form-label") }} + {{ form.event_type(class="form-select") }} +
+ +
+ {{ form.title.label(class="form-label") }} + {{ form.title(class="form-control") }} +
+ +
+ {{ form.notes.label(class="form-label") }} + {{ form.notes(class="form-control", rows=4) }} +
+ +
+ {{ form.is_public(class="form-check-input") }} + {{ form.is_public.label(class="form-check-label") }} +
+ + +
+{% endblock %} diff --git a/plugins/growlog/templates/growlog/log_list.html b/plugins/growlog/templates/growlog/log_list.html index e3f146c..322c7dc 100644 --- a/plugins/growlog/templates/growlog/log_list.html +++ b/plugins/growlog/templates/growlog/log_list.html @@ -1,36 +1,104 @@ {# plugins/growlog/templates/growlog/log_list.html #} -{% import 'core_ui/_media_macros.html' as media %} {% extends 'core_ui/base.html' %} -{% block content %} -

Logs for Plant #{{ plant.id }}

-Add New Log - -
    - {% for log in logs %} -
  • - {{ log.timestamp.strftime('%Y-%m-%d') }}: - {{ log.event_type }} – {{ log.note }} - {% if log.media_items %} -
    Images: -
      - {% for image in log.media_items %} -
    • - Log image
      - {{ image.caption or "No caption" }} -
    • - {% endfor %} -
    - {% endif %} -
  • - {% endfor %} -
- -{# 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) }} +{% block title %} + {% if plant %} + Logs for {{ plant.common_name.name }} + {% else %} + Recent Grow Logs + {% endif %} +{% endblock %} + +{% block content %} +
+

+ {% if plant %} + Grow Logs for {{ plant.common_name.name }} + {% else %} + Recent Grow Logs + {% endif %} +

+ {# β€œAdd” button: carry plant_uuid when in single-plant view #} + + Add Log + +
+ + {% if logs %} +
+ {% for log in logs %} +
+
+
+
{{ log.title or 'Untitled' }}
+ + {{ log.created_at.strftime('%Y-%m-%d %H:%M') }} + +
+ {% if not plant %} + {# Show which plant this log belongs to when listing across all plants #} + + {% endif %} +
+ +

{{ log.notes or 'β€”' }}

+ + + {% if log.is_public %}Public{% else %}Private{% endif %} + + + {% if log.media_items.count() %} +
+ {% for media in log.media_items %} + {{ media.caption or '' }} + {% endfor %} +
+ {% endif %} + +
+ + Edit + +
+ + +
+
+
+ {% endfor %} +
+ {% else %} +

+ No grow logs found{% if plant %} for {{ plant.common_name.name }}{% endif %}. + + Add one now + . +

+ {% endif %} {% endblock %} diff --git a/plugins/plant/routes.py b/plugins/plant/routes.py index 9c9abba..b0fc613 100644 --- a/plugins/plant/routes.py +++ b/plugins/plant/routes.py @@ -40,44 +40,74 @@ def inject_image_helper(): @bp.route('/', methods=['GET']) @login_required 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 - .options(joinedload(Plant.media_items)) - .filter_by(owner_id=current_user.id) - .order_by(Plant.id.desc()) - .all() + .options(joinedload(Plant.media_items)) + .filter_by(owner_id=current_user.id) ) - user_plants_count = Plant.query.filter_by(owner_id=current_user.id).count() - user_images_count = Media.query.filter_by(uploader_id=current_user.id).count() - total_plants_count = Plant.query.count() - total_images_count = Media.query.count() + # ── 3) Optional name search ─────────────────────────────────────── + if q: + qry = qry.join(PlantCommonName).filter( + 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 = [ - pt[0] - for pt in ( - db.session.query(Plant.plant_type) - .filter_by(owner_id=current_user.id) - .distinct() - .all() + row[0] + for row in ( + db.session + .query(Plant.plant_type) + .filter_by(owner_id=current_user.id) + .distinct() + .all() ) ] - stats = { - 'user_plants': user_plants_count, - 'user_images': user_images_count, - 'total_plants': total_plants_count, - 'total_images': total_images_count, - } - + # ── 7) Render, passing both pagination AND per-page items ───────── return render_template( 'plant/index.html', - plants=plants, - plant_types=plant_types, - stats=stats, + plants = plants, + pagination = pagination, + view_mode = view_mode, + q = q, + type_filter = type_filter, + per_page = per_page, + plant_types = plant_types, + stats = stats ) -@bp.route('/', methods=['GET', 'POST']) +@bp.route('/create', methods=['GET', 'POST']) @login_required def create(): form = PlantForm() diff --git a/plugins/plant/templates/plant/index.html b/plugins/plant/templates/plant/index.html index 8ae6472..2e2066c 100644 --- a/plugins/plant/templates/plant/index.html +++ b/plugins/plant/templates/plant/index.html @@ -14,7 +14,114 @@ - +

View Entries

+ + {# ── Import / Export, Stats, Filters & View Toggle ─────────────────────── #} +
+ +
+ + + Export My Data + + + +
+ + +
+
+ Search + +
+ + + + + + {# keep the current view so Apply doesn’t reset it #} + + + + +
+ + + +
+
+
+ +
Statistics
@@ -95,214 +202,168 @@
-

View Entries

- - -
-
- - Export My Data - - -
-
-
- Search - -
- - -
-
- - -
- {% for plant in plants %} -
-
- {# Determine featured image: first any marked featured, else first media #} - {% set featured = plant.media|selectattr('featured')|first %} - {% if not featured and plant.media %} - {% set featured = plant.media[0] %} - {% endif %} - - - Image for {{ plant.common_name.name }} - - -
-
+ {# ── Results (list vs grid) ──────────────────────────────────────────── #} + {% if view_mode=='list' %} +
+ {% for plant in plants %} +
+
+
+ {%- set f = (plant.media_items|selectattr('featured')|first) + or (plant.media_items|first) -%} - {{ plant.common_name.name }} + Image for {{ plant.common_name.name }} -
-
{{ plant.uuid }}
-

Type: {{ plant.plant_type }}

-

Scientific Name: {{ plant.scientific_name.name }}

- {% if plant.mother_uuid %} -

- Mother: - - {{ plant.mother_uuid }} +

+
+
+ + {{ plant.common_name.name }} -

- {% endif %} - -
+ {{ plant.uuid }} +
+ {{ plant.plant_type }} +
+
+
+ View + Edit
-
- {% endfor %} - + {% endfor %} + + {% else %} +
+ {% for plant in plants %} +
+
+ {%- set f = (plant.media_items|selectattr('featured')|first) + or (plant.media_items|first) -%} + + Image for {{ plant.common_name.name }} + +
+
+ + {{ plant.common_name.name }} + +
+ {{ plant.uuid }} +
+ View + Edit +
+
+
+
+ {% endfor %} +
+ {% endif %} - -