Compare commits

...

129 commits

Author SHA1 Message Date
af2ea18e1d bump: version 0.7.0 → 0.8.0 2026-03-04 20:43:03 -06:00
23af35842d feat: improve support for exporting quantity, log data
and make the Eggs quick form save to wuttafarm app DB first, then
export to farmOS, if app is running as mirror
2026-03-04 20:36:56 -06:00
609a900f39 feat: show related Quantity records when viewing a Measure 2026-03-04 16:51:26 -06:00
a547188a90 feat: show related Quantity records when viewing a Unit 2026-03-04 16:49:28 -06:00
81fa22bbd8 feat: show link to Log record when viewing Quantity 2026-03-04 14:49:12 -06:00
b2b49d93ae docs: fix doc warning 2026-03-04 14:20:09 -06:00
7bffa6cba6 fix: bump version requirement for wuttaweb 2026-03-04 14:15:23 -06:00
0a1aee591a bump: version 0.6.0 → 0.7.0 2026-03-04 14:14:52 -06:00
a0f73e6a32 fix: show drupal ID column for asset types 2026-03-04 12:59:55 -06:00
e8a8ce2528 feat: expose "group membership" for assets 2026-03-04 12:59:55 -06:00
b2c3d3a301 fix: remove unique constraint for LandAsset.land_type_uuid
not sure why that was in there..assuming a mistake
2026-03-04 12:59:55 -06:00
759eb906b9 feat: expose "current location" for assets
based on most recent movement log, as in farmOS
2026-03-04 12:59:55 -06:00
41870ee2e2 fix: move farmOS UUID field below the Drupal ID 2026-03-04 12:59:55 -06:00
0ac2485bff feat: add schema, sync support for Log.is_movement 2026-03-04 12:59:55 -06:00
eb16990b0b feat: add schema, import support for Asset.owners 2026-03-04 12:59:55 -06:00
ce103137a5 fix: add links for Parents column in All Assets grid 2026-03-04 12:59:55 -06:00
547cc6e4ae feat: add schema, import support for Log.quick 2026-03-04 12:59:55 -06:00
32d23a7073 feat: show quantities when viewing log 2026-03-04 12:59:55 -06:00
7890b18568 fix: set timestamp for new log in quick eggs form 2026-03-04 12:59:55 -06:00
90ff7eb793 fix: set default grid pagesize to 50
to better match farmOS
2026-03-04 12:59:55 -06:00
d07f3ed716 feat: add sync support for MedicalLog.vet 2026-03-04 12:59:54 -06:00
7d2ae48067 feat: add schema, import support for Log.quantities 2026-03-04 12:59:54 -06:00
1d877545ae feat: add schema, import support for Log.groups 2026-03-04 12:59:54 -06:00
87f3764ebf feat: add schema, import support for Log.locations
still need to add support for edit, export
2026-03-04 12:59:54 -06:00
3ae4d639ec feat: add sync support for Log.is_group_assignment 2026-03-04 12:59:54 -06:00
a5550091d3 feat: add support for exporting log status, timestamp to farmOS 2026-03-04 12:59:54 -06:00
61402c183e fix: add placeholder for log 'quick' field 2026-03-04 12:59:54 -06:00
64e4392a92 feat: add support for log 'owners' 2026-03-04 12:59:54 -06:00
ae73d2f87f fix: define log grid columns to match farmOS
some of these still do not have values yet..
2026-03-04 12:59:54 -06:00
86e36bc64a fix: make AllLogView inherit from LogMasterView
and improve asset rendering for those grids
2026-03-04 12:59:54 -06:00
d1817a3611 fix: rename views for "all records" (all assets, all logs etc.)
just for clarity's sake, i think it's better
2026-03-04 12:59:54 -06:00
d465934818 fix: ensure token refresh works regardless where API client is used 2026-03-04 12:59:54 -06:00
c353d5bcef feat: add support for edit, import/export of plant type data
esp. plant types for a plant asset
2026-03-04 12:59:54 -06:00
bdda586ccd fix: render links for Plant Type column in Plant Assets grid 2026-03-04 12:59:54 -06:00
0d989dcb2c fix: fix land asset type 2026-03-04 12:59:54 -06:00
2f84f76d89 fix: prevent edit for asset types, land types when app is mirror 2026-03-04 12:59:51 -06:00
3343524325 fix: add farmOS-style links for Parents column in Land Assets grid 2026-02-28 22:08:57 -06:00
28ecb4d786 fix: remove unique constraint for AnimalType.name
since it is not guaranteed unique in farmOS; can't do it here either
or else import may fail
2026-02-28 22:08:57 -06:00
338da0208c fix: prevent delete if animal type is still being referenced 2026-02-28 22:08:57 -06:00
ec67340e66 feat: add way to create animal type when editing animal 2026-02-28 22:08:55 -06:00
1c0286eda0 fix: add reminder to restart if changing integration mode 2026-02-28 22:08:55 -06:00
7d5ff47e8e feat: add related version tables for asset/log revision history 2026-02-28 22:08:53 -06:00
5046171b76 fix: prevent edit for user farmos_uuid, drupal_id 2026-02-28 22:08:53 -06:00
f374ae426c fix: remove 'contains' verb for sex filter 2026-02-28 22:08:53 -06:00
2a375b0a6f fix: add enum, row hilite for log status 2026-02-28 22:08:53 -06:00
a5d7f89fcb feat: improve mirror/deletion for assets, logs, animal types 2026-02-28 22:08:51 -06:00
96ccf30e46 feat: auto-delete asset from farmOS if deleting via mirror app 2026-02-28 22:08:48 -06:00
38dad49bbd fix: fix Sex field when empty and deleting an animal 2026-02-26 17:35:05 -06:00
f2be7d0a53 fix: add get_farmos_client_for_user() convenience function 2026-02-26 17:25:49 -06:00
9b4afb845b fix: use current user token for auto-sync within web app
to ensure data writes to farmOS have correct authorship
2026-02-26 17:04:55 -06:00
f4b5f3960c fix: set log type, status enums for log grids 2026-02-25 15:22:25 -06:00
127ea49d74 fix: add more default perms for first site admin user 2026-02-25 14:59:54 -06:00
30e1fd23d6 fix: only show quick form menu if perms allow 2026-02-25 14:59:54 -06:00
df517cfbfa fix: expose config for farmOS OAuth2 client_id and scope
refs: #3
2026-02-25 14:59:46 -06:00
ec6ac443fb fix: add separate permission for each quick form view 2026-02-25 11:22:49 -06:00
11781dd70b bump: version 0.5.0 → 0.6.0 2026-02-25 09:02:12 -06:00
b9ab27523f fix: add Notes schema type
this is because the dict we get from (normalizing the) farmOS API
record will have e.g. `notes=None` but that winds up rendering as
"None" instead of empty string - so we use colander.null value in such
cases so empty string is rendered
2026-02-24 20:03:59 -06:00
331543d74b docs: fix sphinx warnings 2026-02-24 19:57:25 -06:00
e7ef5c3d32 feat: add common normalizer to simplify code in view, importer etc.
only the "log" normalizer exists so far, but will add more..
2026-02-24 16:19:26 -06:00
1a6870b8fe feat: overhaul farmOS log views; add Eggs quick form
probably a few other changes...i'm tired and need a savepoint
2026-02-24 16:19:24 -06:00
ad6ac13d50 feat: add basic CRUD for direct API views: animal types, animal assets 2026-02-21 18:38:08 -06:00
c976d94bdd fix: add grid filter for animal birthdate 2026-02-20 21:37:57 -06:00
5d7dea5a84 fix: add thumbnail to farmOS asset base view 2026-02-20 20:52:08 -06:00
e5e3d38365 fix: add setting to toggle "farmOS-style grid links"
not sure yet if users prefer farmOS style, but will assume so by
default just to be safe.  but i want the "traditional" behavior
myself, so setting is needed either way
2026-02-20 20:38:31 -06:00
1af2b695dc feat: use 'include' API param for better Animal Assets grid data
this commit also renames all farmOS asset routes, for some reason.  at
least now they are consistent
2026-02-20 19:21:49 -06:00
bbb1207b27 feat: add backend filters, sorting for farmOS animal types, assets
could not add pagination due to quirks with how Drupal JSONAPI works
for that.  but so far it looks like we can add filter/sort to all of
the farmOS grids..now just need to do it
2026-02-20 16:10:44 -06:00
9cfa91e091 fix: standardize a bit more for the farmOS Animal Assets view 2026-02-20 14:53:14 -06:00
87101d6b04 feat: include/exclude certain views, menus based on integration mode
refs: #3
2026-02-20 14:53:14 -06:00
1f254ca775 fix: set *default* instead of configured menu handler 2026-02-20 14:53:14 -06:00
d884a761ad fix: expose farmOS integration mode, URL in app settings
although as of now changing the integration mode setting will not
actually change any behavior.. but it will

refs: #3
2026-02-20 14:53:14 -06:00
cfe2e4b7b4 feat: add Standard Quantities table, views, import 2026-02-20 14:53:14 -06:00
c93660ec4a feat: add Quantity Types table, views, import 2026-02-20 14:53:14 -06:00
0a0d43aa9f feat: add Units table, views, import/export 2026-02-20 14:53:13 -06:00
bc0836fc3c fix: reword some menu entries 2026-02-18 19:31:58 -06:00
e7b493d7c9 fix: add WuttaFarm -> farmOS export for Plant Assets 2026-02-18 19:31:41 -06:00
185cd86efb fix: fix default admin user perms, per new log schema 2026-02-18 19:09:39 -06:00
5ee2db267a bump: version 0.4.1 → 0.5.0 2026-02-18 19:03:45 -06:00
26a4746898 feat: add produces_eggs flag for animal, group assets
even if the farmOS instance does not have `farm_eggs` module
installed, we should support the schema
2026-02-18 18:56:40 -06:00
2e0ec73317 feat: add more assets (plant) and logs (harvest, medical, observation) 2026-02-18 18:40:37 -06:00
b061959b18 feat: refactor log models, views to use generic/common base 2026-02-18 13:21:38 -06:00
982da89861 fix: rename db model modules, for better convention 2026-02-18 11:45:07 -06:00
4ec7923164 fix: add override for requests cert validation
for use in local dev with self-signed certs
2026-02-18 11:29:38 -06:00
4bc556aec5 bump: version 0.4.0 → 0.4.1 2026-02-17 20:12:38 -06:00
e520a34fa5 fix: remove AnimalType.changed column
that was just confusing things.  WuttaFarm model should have its own
notion of when things changed, and farmOS can have its own.
2026-02-17 18:13:16 -06:00
d741a88299 docs: update feature list, roadmap, screenshots 2026-02-17 16:54:43 -06:00
36eca08895 bump: version 0.3.1 → 0.4.0 2026-02-17 15:40:03 -06:00
da9b559752 feat: add basic support for WuttaFarm → farmOS export
typical CLI export tool, but also the export happens automatically
when create or edit of record happens in wuttafarm

supported models:

- AnimalType
- AnimalAsset
- GroupAsset
- LandAsset
- StructureAsset
2026-02-17 13:41:50 -06:00
6677fe1e23 fix: misc. field tweaks for asset forms 2026-02-16 15:09:50 -06:00
b85259c013 fix: show warning when viewing an archived asset 2026-02-16 15:09:50 -06:00
bb21d6a364 fix: fix some perms for all assets view 2026-02-15 14:11:14 -06:00
ec89230893 fix: fix initial admin perms per route renaming 2026-02-15 14:08:01 -06:00
2fc9c88cd5 feat: convert group assets to use common base/mixin 2026-02-15 14:07:03 -06:00
3435b4714e feat: convert structure assets to use common base/mixin 2026-02-15 13:38:38 -06:00
7b6280b6dc feat: convert land assets to use common base/mixin 2026-02-15 13:38:36 -06:00
140f3cbdba feat: add "generic" assets, new animal assets based on that 2026-02-15 11:11:13 -06:00
ac084c4e79 fix: add parent relationships support for land assets
this may not be complete yet, we'll see.  works for the simple case afaik
2026-02-14 22:50:34 -06:00
71592e883a fix: cleanup Land views to better match farmOS 2026-02-14 20:33:54 -06:00
e60b91fd45 fix: cleanup Structure views to better match farmOS 2026-02-14 20:18:35 -06:00
aae01c010b fix: cleanup Group views to better match farmOS 2026-02-14 20:06:09 -06:00
5e4cd8978d fix: add / display thumbnail image for animals 2026-02-14 20:00:39 -06:00
e120812eae fix: improve handling of 'archived' records for grid/form views 2026-02-14 19:35:42 -06:00
25b2dc6cec fix: use Male/Female dict enum for animal sex field
and some related changes to make Animal views more like farmOS
2026-02-14 19:35:40 -06:00
df4536741d fix: prevent direct edit of farmos_uuid and drupal_id fields 2026-02-14 19:25:34 -06:00
e9161e8c93 fix: use same datetime display format as farmOS
at least i think this is right..anyway this is how you change it
2026-02-14 19:24:16 -06:00
4ed61380de fix: convert active flag to archived
to better mirror farmOS
2026-02-14 18:52:49 -06:00
98be276bd1 fix: suppress output when user farmos/drupal keys are empty 2026-02-14 15:43:47 -06:00
96d575feb7 fix: customize page footer to mention farmOS 2026-02-14 15:07:10 -06:00
02d022295c bump: version 0.3.0 → 0.3.1 2026-02-14 15:04:44 -06:00
985d224cb8 fix: update sterile, archived flags per farmOS 4.x
3.x should still work okay too though
2026-02-14 14:54:12 -06:00
35068c0cb1 docs: add some notes on email setup 2026-02-14 08:44:24 -06:00
34cb6b210d bump: version 0.2.3 → 0.3.0 2026-02-13 15:51:52 -06:00
061dac39f9 docs: add basic docs for oauth2 setup, import data from farmOS 2026-02-13 15:50:32 -06:00
be64b4959a docs: add basic docs for CLI commands 2026-02-13 15:18:53 -06:00
311a2c328b fix: always make 'farmos' system user in app setup
mainly for sake of attributing data changes coming from farmOS
2026-02-13 15:11:10 -06:00
935c64464a fix: avoid error for Create User form 2026-02-13 15:07:48 -06:00
1dbf14f3bb fix: add more perms to Site Admin role in app setup 2026-02-13 15:07:48 -06:00
ed768a83d0 feat: add native table for Activity Logs; import from farmOS API 2026-02-13 14:53:02 -06:00
f4e4c3efb3 fix: rename drupal_internal_id => drupal_id 2026-02-13 14:53:02 -06:00
81daa5d913 feat: add native table for Groups; import from farmOS API 2026-02-13 14:53:02 -06:00
3e5ca3483e feat: add native table for Animals; import from farmOS API 2026-02-13 14:52:58 -06:00
c38d00a7cc feat: add native table for Structures; import from farmOS API 2026-02-13 12:28:54 -06:00
1d898cb580 feat: add native table for Land Assets; import from farmOS API 2026-02-13 10:43:34 -06:00
6204db8ae3 feat: add native table for Log Types; import from farmOS API 2026-02-10 19:51:08 -06:00
5189c12f43 feat: add native table for Structure Types; import from farmOS API 2026-02-10 19:43:20 -06:00
b573ae459e feat: add native table for Land Types; import from farmOS API 2026-02-10 19:43:18 -06:00
10666de488 feat: add native table for Asset Types; import from farmOS API 2026-02-10 19:21:01 -06:00
fd2f09fcf3 feat: add extension table for Users; import from farmOS API 2026-02-10 19:21:01 -06:00
4a517bf7bf feat: add native table for Animal Types; import from farmOS API 2026-02-10 19:20:59 -06:00
09042747a0 feat: add "See raw JSON data" button for farmOS API views 2026-02-10 18:30:35 -06:00
142 changed files with 19146 additions and 410 deletions

View file

@ -5,6 +5,179 @@ All notable changes to WuttaFarm will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.8.0 (2026-03-04)
### Feat
- improve support for exporting quantity, log data
- show related Quantity records when viewing a Measure
- show related Quantity records when viewing a Unit
- show link to Log record when viewing Quantity
### Fix
- bump version requirement for wuttaweb
## v0.7.0 (2026-03-04)
### Feat
- expose "group membership" for assets
- expose "current location" for assets
- add schema, sync support for `Log.is_movement`
- add schema, import support for `Asset.owners`
- add schema, import support for `Log.quick`
- show quantities when viewing log
- add sync support for `MedicalLog.vet`
- add schema, import support for `Log.quantities`
- add schema, import support for `Log.groups`
- add schema, import support for `Log.locations`
- add sync support for `Log.is_group_assignment`
- add support for exporting log status, timestamp to farmOS
- add support for log 'owners'
- add support for edit, import/export of plant type data
- add way to create animal type when editing animal
- add related version tables for asset/log revision history
- improve mirror/deletion for assets, logs, animal types
- auto-delete asset from farmOS if deleting via mirror app
### Fix
- show drupal ID column for asset types
- remove unique constraint for `LandAsset.land_type_uuid`
- move farmOS UUID field below the Drupal ID
- add links for Parents column in All Assets grid
- set timestamp for new log in quick eggs form
- set default grid pagesize to 50
- add placeholder for log 'quick' field
- define log grid columns to match farmOS
- make AllLogView inherit from LogMasterView
- rename views for "all records" (all assets, all logs etc.)
- ensure token refresh works regardless where API client is used
- render links for Plant Type column in Plant Assets grid
- fix land asset type
- prevent edit for asset types, land types when app is mirror
- add farmOS-style links for Parents column in Land Assets grid
- remove unique constraint for `AnimalType.name`
- prevent delete if animal type is still being referenced
- add reminder to restart if changing integration mode
- prevent edit for user farmos_uuid, drupal_id
- remove 'contains' verb for sex filter
- add enum, row hilite for log status
- fix Sex field when empty and deleting an animal
- add `get_farmos_client_for_user()` convenience function
- use current user token for auto-sync within web app
- set log type, status enums for log grids
- add more default perms for first site admin user
- only show quick form menu if perms allow
- expose config for farmOS OAuth2 client_id and scope
- add separate permission for each quick form view
## v0.6.0 (2026-02-25)
### Feat
- add common normalizer to simplify code in view, importer etc.
- overhaul farmOS log views; add Eggs quick form
- add basic CRUD for direct API views: animal types, animal assets
- use 'include' API param for better Animal Assets grid data
- add backend filters, sorting for farmOS animal types, assets
- include/exclude certain views, menus based on integration mode
- add Standard Quantities table, views, import
- add Quantity Types table, views, import
- add Units table, views, import/export
### Fix
- add `Notes` schema type
- add grid filter for animal birthdate
- add thumbnail to farmOS asset base view
- add setting to toggle "farmOS-style grid links"
- standardize a bit more for the farmOS Animal Assets view
- set *default* instead of configured menu handler
- expose farmOS integration mode, URL in app settings
- reword some menu entries
- add WuttaFarm -> farmOS export for Plant Assets
- fix default admin user perms, per new log schema
## v0.5.0 (2026-02-18)
### Feat
- add `produces_eggs` flag for animal, group assets
- add more assets (plant) and logs (harvest, medical, observation)
- refactor log models, views to use generic/common base
### Fix
- rename db model modules, for better convention
- add override for requests cert validation
## v0.4.1 (2026-02-17)
### Fix
- remove `AnimalType.changed` column
## v0.4.0 (2026-02-17)
### Feat
- add basic support for WuttaFarm → farmOS export
- convert group assets to use common base/mixin
- convert structure assets to use common base/mixin
- convert land assets to use common base/mixin
- add "generic" assets, new animal assets based on that
### Fix
- misc. field tweaks for asset forms
- show warning when viewing an archived asset
- fix some perms for all assets view
- fix initial admin perms per route renaming
- add parent relationships support for land assets
- cleanup Land views to better match farmOS
- cleanup Structure views to better match farmOS
- cleanup Group views to better match farmOS
- add / display thumbnail image for animals
- improve handling of 'archived' records for grid/form views
- use Male/Female dict enum for animal sex field
- prevent direct edit of `farmos_uuid` and `drupal_id` fields
- use same datetime display format as farmOS
- convert `active` flag to `archived`
- suppress output when user farmos/drupal keys are empty
- customize page footer to mention farmOS
## v0.3.1 (2026-02-14)
### Fix
- update sterile, archived flags per farmOS 4.x
## v0.3.0 (2026-02-13)
### Feat
- add native table for Activity Logs; import from farmOS API
- add native table for Groups; import from farmOS API
- add native table for Animals; import from farmOS API
- add native table for Structures; import from farmOS API
- add native table for Land Assets; import from farmOS API
- add native table for Log Types; import from farmOS API
- add native table for Structure Types; import from farmOS API
- add native table for Land Types; import from farmOS API
- add native table for Asset Types; import from farmOS API
- add extension table for Users; import from farmOS API
- add native table for Animal Types; import from farmOS API
- add "See raw JSON data" button for farmOS API views
### Fix
- always make 'farmos' system user in app setup
- avoid error for Create User form
- add more perms to Site Admin role in app setup
- rename `drupal_internal_id` => `drupal_id`
## v0.2.3 (2026-02-08)
### Fix

View file

@ -0,0 +1,6 @@
``wuttafarm.cli.base``
======================
.. automodule:: wuttafarm.cli.base
:members:

View file

@ -0,0 +1,6 @@
``wuttafarm.cli.import_farmos``
===============================
.. automodule:: wuttafarm.cli.import_farmos
:members:

View file

@ -0,0 +1,6 @@
``wuttafarm.cli.install``
=========================
.. automodule:: wuttafarm.cli.install
:members:

View file

@ -0,0 +1,6 @@
``wuttafarm.importing.farmos``
==============================
.. automodule:: wuttafarm.importing.farmos
:members:

View file

@ -0,0 +1,6 @@
``wuttafarm.importing``
=======================
.. automodule:: wuttafarm.importing
:members:

View file

@ -21,6 +21,7 @@ extensions = [
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
"sphinx.ext.todo",
"sphinxcontrib.programoutput",
]
templates_path = ["_templates"]

View file

@ -8,9 +8,6 @@ and extend `farmOS`_.
.. _WuttaWeb: https://wuttaproject.org
.. _farmOS: https://farmos.org
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/psf/black
It is just an experiment so far; the ideas I hope to play with
include:
@ -19,6 +16,9 @@ include:
- possibly add more schema / extra features
- possibly sync data back to farmOS
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/psf/black
.. toctree::
:maxdepth: 2
@ -27,6 +27,7 @@ include:
narr/install
narr/auth
narr/features
narr/cli
.. toctree::
@ -37,11 +38,16 @@ include:
api/wuttafarm.app
api/wuttafarm.auth
api/wuttafarm.cli
api/wuttafarm.cli.base
api/wuttafarm.cli.import_farmos
api/wuttafarm.cli.install
api/wuttafarm.config
api/wuttafarm.db
api/wuttafarm.db.model
api/wuttafarm.farmos
api/wuttafarm.farmos.handler
api/wuttafarm.importing
api/wuttafarm.importing.farmos
api/wuttafarm.install
api/wuttafarm.web
api/wuttafarm.web.app

View file

@ -36,7 +36,13 @@ browse farmOS data within the WuttaFarm views.
If you login to WuttaFarm directly with username/password, then
your user session will not have a farmOS access token and so the
farmOS data views in WuttaFarm will not work.
farmOS data views in WuttaFarm will not work (i.e. anything under
the **farmOS** menu).
(However this does not affect the "native" data views for
WuttaFarm. Users can see data which was already imported from
farmOS without an access token - if they have appropriate
permissions in WuttaFarm.)
On the login page, click the "Login via farmOS / OAuth2" button. This
will initiate the OAuth2 workflow, at which point you may be asked to

39
docs/narr/cli.rst Normal file
View file

@ -0,0 +1,39 @@
========================
Command Line Interface
========================
WuttaFarm ships with the following commands.
For more general info about CLI see
:doc:`wuttjamaican:narr/cli/index`.
.. _wuttafarm-install:
``wuttafarm install``
---------------------
Run the WuttaFarm app installer.
This will create the :term:`app dir` and initial config files, and
create the schema within the :term:`app database`.
Defined in: :mod:`wuttafarm.cli.install`
.. program-output:: wuttafarm install --help
.. _wuttafarm-import-farmos:
``wuttafarm import-farmos``
---------------------------
Import data from the farmOS API into the WuttaFarm :term:`app
database`.
Defined in: :mod:`wuttafarm.cli.import_farmos`
.. program-output:: wuttafarm import-farmos --help

View file

@ -14,8 +14,109 @@ Here is the list of features currently supported:
* performance isn't bad, but data is not very "complete"
* more data could be fetched, but not sure this is the best way..?
* import some data from farmOS
* limited data is imported from farmOS API into native app tables
* this data is exposed in views, similar to direct farmOS views (above)
* export some data back to farmOS
* limited data is exported back via farmOS API, from native tables
* supported tables are auto-synced when a record is created/updated
* AnimalType
* AnimalAsset
* GroupAsset
* LandAsset
* StructureAsset
How I Use This App
------------------
My production farmOS instance is deployed via Podman container, which
I prefer over Docker. (Not that I know much about any of that
really.) It has a PostgreSQL database which runs in a separate
container.
My production WuttaFarm instance is installed directly on the same
host machine, in a Python virtual environment. PostgreSQL is also
installed on the host machine; the app uses that for DB.
I ran the initial "special" import to establish the user accounts;
then I ran the "full" import (farmOS → WuttaFarm). See also
:doc:`/narr/install`.
I configured a cron job to run the full import every night, but in
dry-run mode with warnings. This means I will get an email if
WuttaFarm is ever out of sync with farmOS.
With all that in place, I can use WuttaFarm as my "daily driver" to
add/edit assets (and soon, logs). Changes I make are immediately
synced to farmOS, so as long as the overnight check does not send me
an email, I know everything is good.
Roadmap
-------
Here are some things I still have planned so far:
* finish support for auto-sync, in current asset models
* must make "asset parents" editable
* add more asset models?
* i may only add those i need for now, but others can add more
* flesh out the log model support
* add more tables, fields to schema
* add/improve import and export
* basically this should be as good as the asset model support
* although again i may only add those i need for now
* add custom "quick forms" for assets and logs
* again i probably will just add a few (e.g. egg collection)
* but this could be an interesting path to go down, we'll see
* add custom "CSV/file importers"
* the framework has some pretty neat tools around this, so..
* ..even if i don't need CSV import i'd like to show what's possible
Notably **off the table** for now are:
* anything involving maps
* file/image attachments
I will just import "thumbnail" and "large" image URLs from farmOS for
each asset for now. Will have to think more on the image/attachment
stuff before I'll know if/how to add support in WuttaFarm.
Maps will wait mostly because I have never done anything involving
those (or GIS etc. - if that's even the right term). And anyway the
main "use" for this app is probably around data entry, so it may never
"need" maps support.
Screenshots
-----------
.. image:: https://wuttaproject.org/images/screenshot.png
Login Screen
~~~~~~~~~~~~
.. image:: https://wuttaproject.org/images/wuttafarm/screenshot001.png
List All Assets
~~~~~~~~~~~~~~~
.. image:: https://wuttaproject.org/images/wuttafarm/screenshot002.png
View Animal Asset
~~~~~~~~~~~~~~~~~
.. image:: https://wuttaproject.org/images/wuttafarm/screenshot003.png
Edit Animal Asset
~~~~~~~~~~~~~~~~~
.. image:: https://wuttaproject.org/images/wuttafarm/screenshot004.png

View file

@ -53,10 +53,133 @@ The app installer (last command above) will prompt you for DB
credentials, and the farmOS URL.
One of the questions is about data versioning with
:doc:`wutta-continuum:index`. This feature will be leveraged more in
the future but for the moment doesn't do a whole lot in this app. You
are encouraged to enable it anyway.
:doc:`wutta-continuum:index`. You should probaby enable that even
though as of writing the default is disabled. It adds "revision
history" for most types of records in the WuttaFarm app DB.
When the installer completes it will output a command you can then use
to run the web app. Do that and you can then view the app in a
browser at http://localhost:9080
OAuth2 Setup
------------
At this point the web app should be ready for OAuth2 login; however
the OAuth2 provider in farmOS needs some more config before it will
work.
WuttaFarm uses the default ``farm`` consumer, so the only thing you
should have to do here is edit that to add your redirect URL. This
will vary based on your WuttaFarm site name, e.g.
.. code-block:: none
https://wuttafarm.example.com/farmos/oauth/callback
With that in place you should be able to login via OAuth2; see also
:doc:`/narr/auth`.
However while you're there, you should also do some setup for the sake
of the farmOS → WuttaFarm data import. This import will also use the
farmOS API and therefore also needs an oauth2 access token; however it
uses the Client Credentials workflow instead of the Authorization Code
workflow. Therefore you must create a new *user* and a new OAuth2
*consumer* for it.
First add a new user in farmOS, named ``wuttafarm``. It should
probably be given the Manager role, since WuttaFarm will eventually
also support "exporting" data back to farmOS.
Then add a new OAuth2 consumer (aka. client) with these attributes:
* **Label:** WuttaFarm
* **Client ID:** wuttafarm
* **New Secret:** (put something in here, to be used as client secret)
* **Grant Types:** Client Credentials, Refresh Token (maybe more?)
* **User:** wuttafarm
* **3rd Party?** yes
* **Confidential?** yes
* **Access Token Expiration Time:** maybe set to 3600? or maybe 300
default is okay?
* **Allowed Origins:** put your oauth callback URL here (same as for
default ``farm`` consumer)
WuttaFarm also needs to know the client secret for sake of running the
import; so add this to your ``app/wutta.conf`` file. Of course
replace the value with whatever client secret you gave the new
consumer:
.. code-block:: ini
[farmos.oauth2]
importing.client_secret = you_cant_guess_me
Email Setup
-----------
WuttaFarm can send emails of various kinds; of note are:
* when user submits Feedback via button in top right of screen
* importer diff warning for farmOS → WuttaFarm
That last one is optional, triggered via the ``-W`` flag in the
importer command line.
Anyway the app basically assumes there is a Postfix or similar mail
server running on "localhost" which it can use as the SMTP server, and
which is in turn responsible for "really" sending the email out via
some configured relay. This has always worked very well for me since
I tend to want to have email working for other reasons on each Linux
server I maintain. (And since I have not traditionally used Docker
and/or containers.)
So if you need something else, touch base and we'll figure something
out. But assuming localhost is okay to use:
In the web app menu, see Admin -> App Info and then click Configure.
Check the box to enable email and plug in the default sender and
recipient (which should be the admin responsible for the app). I
often create an alias so I can use e.g. wuttafarm@edbob.org as
sender - aliased back to myself in case it generates bounces so I can
see them.
From there you can also see Admin -> Email Settings in the menu; this
lets you control and preview each type of email separately.
Import Data from farmOS
-----------------------
You must have done all the OAuth2 setup (previous section) before the
import will work.
But now that you did all that, importing should be quick and easy.
The very first import will be limited and "special" to account for any
users which were already created in WuttaFarm. This command will
ensure WuttaFarm gets *all* user accounts and each is appropriately
mapped to the farmOS account:
.. code-block:: sh
./venv/bin/wuttafarm --runas farmos import-farmos User --key username
Note also the ``--runas farmos`` arg which helps the WuttaFarm data
versioning know "who" is responsible for the changes. We use a
dedicated ``farmos`` user account in WuttaFarm, to represent the
farmOS system as a whole.
From now on you can run the "full" import normally:
.. code-block:: sh
./venv/bin/wuttafarm --runas farmos import-farmos
And it can sometimes be helpful to "double-check" in order to make
sure all data is fully synced:
.. code-block:: sh
./venv/bin/wuttafarm --runas farmos import-farmos --delete --dry-run -W

View file

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaFarm"
version = "0.2.3"
version = "0.8.0"
description = "Web app to integrate with and extend farmOS"
readme = "README.md"
authors = [
@ -33,12 +33,13 @@ dependencies = [
"psycopg2",
"pyramid_exclog",
"uvicorn[standard]",
"WuttaWeb[continuum]>=0.27.4",
"WuttaSync",
"WuttaWeb[continuum]>=0.29.0",
]
[project.optional-dependencies]
docs = ["Sphinx", "furo"]
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"]
[project.scripts]
@ -47,12 +48,19 @@ docs = ["Sphinx", "furo"]
[project.entry-points."paste.app_factory"]
"main" = "wuttafarm.web.app:main"
[project.entry-points."wutta.app.providers"]
wuttafarm = "wuttafarm.app:WuttaFarmAppProvider"
[project.entry-points."wutta.config.extensions"]
"wuttafarm" = "wuttafarm.config:WuttaFarmConfig"
[project.entry-points."wutta.web.menus"]
"wuttafarm" = "wuttafarm.web.menus:WuttaFarmMenuHandler"
[project.entry-points."wuttasync.importing"]
"export.to_farmos.from_wuttafarm" = "wuttafarm.farmos.importing.wuttafarm:FromWuttaFarmToFarmOS"
"import.to_wuttafarm.from_farmos" = "wuttafarm.importing.farmos:FromFarmOSToWuttaFarm"
[project.urls]
Homepage = "https://forgejo.wuttaproject.org/wutta/wuttafarm"

View file

@ -31,9 +31,26 @@ class WuttaFarmAppHandler(base.AppHandler):
Custom :term:`app handler` for WuttaFarm.
"""
display_format_datetime = "%a, %m/%d/%Y - %H:%M"
default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler"
default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler"
def get_asset_handler(self):
"""
Get the configured asset handler.
:rtype: :class:`~wuttafarm.assets.AssetHandler`
"""
if "asset" not in self.handlers:
spec = self.config.get(
f"{self.appname}.asset_handler",
default="wuttafarm.assets:AssetHandler",
)
factory = self.load_object(spec)
self.handlers["asset"] = factory(self.config)
return self.handlers["asset"]
def get_farmos_handler(self):
"""
Get the configured farmOS integration handler.
@ -49,6 +66,44 @@ class WuttaFarmAppHandler(base.AppHandler):
self.handlers["farmos"] = factory(self.config)
return self.handlers["farmos"]
def get_farmos_integration_mode(self):
"""
Returns the integration mode for farmOS, i.e. to control the
app's behavior regarding that.
"""
enum = self.enum
return self.config.get(
f"{self.appname}.farmos_integration_mode",
default=enum.FARMOS_INTEGRATION_MODE_WRAPPER,
)
def is_farmos_mirror(self):
"""
Returns ``True`` if the app is configured in "mirror"
integration mode with regard to farmOS.
"""
enum = self.enum
mode = self.get_farmos_integration_mode()
return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR
def is_farmos_wrapper(self):
"""
Returns ``True`` if the app is configured in "wrapper"
integration mode with regard to farmOS.
"""
enum = self.enum
mode = self.get_farmos_integration_mode()
return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER
def is_standalone(self):
"""
Returns ``True`` if the app is configured in "standalone" mode
with regard to farmOS.
"""
enum = self.enum
mode = self.get_farmos_integration_mode()
return mode == enum.FARMOS_INTEGRATION_MODE_NONE
def get_farmos_url(self, *args, **kwargs):
"""
Get a farmOS URL. This is a convenience wrapper around
@ -64,3 +119,108 @@ class WuttaFarmAppHandler(base.AppHandler):
"""
handler = self.get_farmos_handler()
return handler.get_farmos_client(*args, **kwargs)
def is_farmos_3x(self, *args, **kwargs):
"""
Check if the farmOS version is 3.x. This is a convenience
wrapper around
:meth:`~wuttafarm.farmos.handler.FarmOSHandler.is_farmos_3x()`.
"""
handler = self.get_farmos_handler()
return handler.is_farmos_3x(*args, **kwargs)
def is_farmos_4x(self, *args, **kwargs):
"""
Check if the farmOS version is 4.x. This is a convenience
wrapper around
:meth:`~wuttafarm.farmos.handler.FarmOSHandler.is_farmos_4x()`.
"""
handler = self.get_farmos_handler()
return handler.is_farmos_4x(*args, **kwargs)
def get_normalizer(self, farmos_client=None):
"""
Get the configured farmOS integration handler.
:rtype: :class:`~wuttafarm.farmos.FarmOSHandler`
"""
spec = self.config.get(
f"{self.appname}.normalizer_spec",
default="wuttafarm.normal:Normalizer",
)
factory = self.load_object(spec)
return factory(self.config, farmos_client)
def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True):
"""
Export the given object to farmOS, using configured handler.
This should ensure the given object is also *updated* with the
farmOS UUID and Drupal ID, when new record is created in
farmOS.
:param obj: Any data object in WuttaFarm, e.g. AnimalAsset
instance.
:param client: Existing farmOS API client to use. If not
specified, a new one will be instantiated.
:param require: If true, this will *require* the export
handler to support objects of the given type. If false,
then nothing will happen / export is silently skipped when
there is no such exporter.
"""
handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm")
if not model_name:
model_name = type(obj).__name__
if model_name not in handler.importers:
if require:
raise ValueError(f"no exporter found for {model_name}")
return
# nb. begin txn to establish the API client
handler.begin_target_transaction(client)
importer = handler.get_importer(model_name, caches_target=False)
normal = importer.normalize_source_object(obj)
importer.process_data(source_data=[normal])
def auto_sync_from_farmos(self, obj, model_name, client=None, require=True):
"""
Import the given object from farmOS, using configured handler.
:param obj: Any data record from farmOS.
:param model_name': Model name for the importer to use,
e.g. ``"AnimalAsset"``.
:param client: Existing farmOS API client to use. If not
specified, a new one will be instantiated.
:param require: If true, this will *require* the import
handler to support objects of the given type. If false,
then nothing will happen / import is silently skipped when
there is no such importer.
"""
handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos")
if model_name not in handler.importers:
if require:
raise ValueError(f"no importer found for {model_name}")
return
# nb. begin txn to establish the API client
handler.begin_source_transaction(client)
with self.short_session(commit=True) as session:
handler.target_session = session
importer = handler.get_importer(model_name, caches_target=False)
normal = importer.normalize_source_object(obj)
importer.process_data(source_data=[normal])
class WuttaFarmAppProvider(base.AppProvider):
"""
The :term:`app provider` for WuttaFarm.
"""
email_modules = ["wuttafarm.emails"]

65
src/wuttafarm/assets.py Normal file
View file

@ -0,0 +1,65 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Asset handler
"""
from wuttjamaican.app import GenericHandler
class AssetHandler(GenericHandler):
"""
Base class and default implementation for the asset
:term:`handler`.
"""
def get_groups(self, asset):
model = self.app.model
session = self.app.get_session(asset)
grplog = (
session.query(model.Log)
.join(model.LogAsset)
.filter(model.LogAsset.asset == asset)
.filter(model.Log.is_group_assignment == True)
.order_by(model.Log.timestamp.desc())
.first()
)
if grplog:
return grplog.groups
return []
def get_locations(self, asset):
model = self.app.model
session = self.app.get_session(asset)
loclog = (
session.query(model.Log)
.join(model.LogAsset)
.filter(model.LogAsset.asset == asset)
.filter(model.Log.is_movement == True)
.order_by(model.Log.timestamp.desc())
.first()
)
if loclog:
return loclog.locations
return []

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttaFarm CLI
"""
from .base import wuttafarm_typer
# nb. must bring in all modules for discovery to work
from . import export_farmos
from . import import_farmos
from . import install

31
src/wuttafarm/cli/base.py Normal file
View file

@ -0,0 +1,31 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttaFarm CLI - base Typer instance
"""
from wuttjamaican.cli import make_typer
wuttafarm_typer = make_typer(
name="wuttafarm", help="WuttaFarm -- Web app to integrate with and extend farmOS"
)

View file

@ -0,0 +1,41 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
See also: :ref:`wuttafarm-export-farmos`
"""
import typer
from wuttasync.cli import import_command, ImportCommandHandler
from wuttafarm.cli import wuttafarm_typer
@wuttafarm_typer.command()
@import_command
def export_farmos(ctx: typer.Context, **kwargs):
"""
Export data from WuttaFarm to farmOS API
"""
config = ctx.parent.wutta_config
handler = ImportCommandHandler(config, key="export.to_farmos.from_wuttafarm")
handler.run(ctx)

View file

@ -0,0 +1,41 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
See also: :ref:`wuttafarm-import-farmos`
"""
import typer
from wuttasync.cli import import_command, ImportCommandHandler
from wuttafarm.cli import wuttafarm_typer
@wuttafarm_typer.command()
@import_command
def import_farmos(ctx: typer.Context, **kwargs):
"""
Import data from farmOS API to WuttaFarm
"""
config = ctx.parent.wutta_config
handler = ImportCommandHandler(config, key="import.to_wuttafarm.from_farmos")
handler.run(ctx)

View file

@ -25,12 +25,7 @@ WuttaFarm CLI
import typer
from wuttjamaican.cli import make_typer
wuttafarm_typer = make_typer(
name="wuttafarm", help="WuttaFarm -- Web app to integrate with and extend farmOS"
)
from wuttafarm.cli import wuttafarm_typer
@wuttafarm_typer.command()

View file

@ -23,6 +23,8 @@
WuttaFarm config extensions
"""
import os
from wuttjamaican.conf import WuttaConfigExtension
@ -39,19 +41,26 @@ class WuttaFarmConfig(WuttaConfigExtension):
config.setdefault(f"{config.appname}.app_title", "WuttaFarm")
config.setdefault(f"{config.appname}.app_dist", "WuttaFarm")
# app model
# app model/enum
config.setdefault(f"{config.appname}.model_spec", "wuttafarm.db.model")
config.setdefault(f"{config.appname}.enum_spec", "wuttafarm.enum")
# app handler
config.setdefault(
f"{config.appname}.app.handler", "wuttafarm.app:WuttaFarmAppHandler"
)
# web app menu
# web app stuff
config.setdefault(
f"{config.appname}.web.menus.handler.spec",
f"{config.appname}.web.menus.handler.default_spec",
"wuttafarm.web.menus:WuttaFarmMenuHandler",
)
config.setdefault("wuttaweb.grids.default_pagesize", "50")
# web app libcache
# config.setdefault('wuttaweb.static_libcache.module', 'wuttafarm.web.static')
# maybe override cert validation for requests lib.
# nb. this is "global" and not "specific" to the farmos API requests!
if bundle := config.get(f"{config.appname}.requests_ca_bundle"):
os.environ.setdefault("REQUESTS_CA_BUNDLE", bundle)

View file

@ -0,0 +1,37 @@
"""add Log.is_movement
Revision ID: 0771322957bd
Revises: 12de43facb95
Create Date: 2026-03-02 20:21:03.889847
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "0771322957bd"
down_revision: Union[str, None] = "12de43facb95"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log
op.add_column("log", sa.Column("is_movement", sa.Boolean(), nullable=True))
op.add_column(
"log_version",
sa.Column("is_movement", sa.Boolean(), autoincrement=False, nullable=True),
)
def downgrade() -> None:
# log
op.drop_column("log_version", "is_movement")
op.drop_column("log", "is_movement")

View file

@ -0,0 +1,596 @@
"""add Plant Assets and more Logs
Revision ID: 11e0e46f48a6
Revises: dd6351e69233
Create Date: 2026-02-18 18:11:46.536930
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "11e0e46f48a6"
down_revision: Union[str, None] = "dd6351e69233"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# plant_type
op.create_table(
"plant_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_plant_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_plant_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_plant_type_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_plant_type_name")),
)
op.create_table(
"plant_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"description", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_plant_type_version")
),
)
op.create_index(
op.f("ix_plant_type_version_end_transaction_id"),
"plant_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_plant_type_version_operation_type"),
"plant_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_plant_type_version_pk_transaction_id",
"plant_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_plant_type_version_pk_validity",
"plant_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_plant_type_version_transaction_id"),
"plant_type_version",
["transaction_id"],
unique=False,
)
# asset_plant
op.create_table(
"asset_plant",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_plant_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant")),
)
op.create_table(
"asset_plant_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_plant_version")
),
)
op.create_index(
op.f("ix_asset_plant_version_end_transaction_id"),
"asset_plant_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_plant_version_operation_type"),
"asset_plant_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_plant_version_pk_transaction_id",
"asset_plant_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_plant_version_pk_validity",
"asset_plant_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_plant_version_transaction_id"),
"asset_plant_version",
["transaction_id"],
unique=False,
)
# asset_plant_plant_type
op.create_table(
"asset_plant_plant_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("plant_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("plant_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["plant_asset_uuid"],
["asset_plant.uuid"],
name=op.f("fk_asset_plant_plant_type_plant_asset_uuid_asset_plant"),
),
sa.ForeignKeyConstraint(
["plant_type_uuid"],
["plant_type.uuid"],
name=op.f("fk_asset_plant_plant_type_plant_type_uuid_plant_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant_plant_type")),
)
op.create_table(
"asset_plant_plant_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"plant_asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"plant_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_plant_plant_type_version")
),
)
op.create_index(
op.f("ix_asset_plant_plant_type_version_end_transaction_id"),
"asset_plant_plant_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_plant_plant_type_version_operation_type"),
"asset_plant_plant_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_plant_plant_type_version_pk_transaction_id",
"asset_plant_plant_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_plant_plant_type_version_pk_validity",
"asset_plant_plant_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_plant_plant_type_version_transaction_id"),
"asset_plant_plant_type_version",
["transaction_id"],
unique=False,
)
# log_asset
op.create_table(
"log_asset",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"], ["asset.uuid"], name=op.f("fk_log_asset_asset_uuid_asset")
),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_asset_log_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_asset")),
)
op.create_table(
"log_asset_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_asset_version")
),
)
op.create_index(
op.f("ix_log_asset_version_end_transaction_id"),
"log_asset_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_asset_version_operation_type"),
"log_asset_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_asset_version_pk_transaction_id",
"log_asset_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_asset_version_pk_validity",
"log_asset_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_asset_version_transaction_id"),
"log_asset_version",
["transaction_id"],
unique=False,
)
# log_harvest
op.create_table(
"log_harvest",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["log.uuid"], name=op.f("fk_log_harvest_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_harvest")),
)
op.create_table(
"log_harvest_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_harvest_version")
),
)
op.create_index(
op.f("ix_log_harvest_version_end_transaction_id"),
"log_harvest_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_harvest_version_operation_type"),
"log_harvest_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_harvest_version_pk_transaction_id",
"log_harvest_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_harvest_version_pk_validity",
"log_harvest_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_harvest_version_transaction_id"),
"log_harvest_version",
["transaction_id"],
unique=False,
)
# log_medical
op.create_table(
"log_medical",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["log.uuid"], name=op.f("fk_log_medical_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_medical")),
)
op.create_table(
"log_medical_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_medical_version")
),
)
op.create_index(
op.f("ix_log_medical_version_end_transaction_id"),
"log_medical_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_medical_version_operation_type"),
"log_medical_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_medical_version_pk_transaction_id",
"log_medical_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_medical_version_pk_validity",
"log_medical_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_medical_version_transaction_id"),
"log_medical_version",
["transaction_id"],
unique=False,
)
# log_observation
op.create_table(
"log_observation",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["log.uuid"], name=op.f("fk_log_observation_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_observation")),
)
op.create_table(
"log_observation_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_observation_version")
),
)
op.create_index(
op.f("ix_log_observation_version_end_transaction_id"),
"log_observation_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_observation_version_operation_type"),
"log_observation_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_observation_version_pk_transaction_id",
"log_observation_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_observation_version_pk_validity",
"log_observation_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_observation_version_transaction_id"),
"log_observation_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_observation
op.drop_index(
op.f("ix_log_observation_version_transaction_id"),
table_name="log_observation_version",
)
op.drop_index(
"ix_log_observation_version_pk_validity", table_name="log_observation_version"
)
op.drop_index(
"ix_log_observation_version_pk_transaction_id",
table_name="log_observation_version",
)
op.drop_index(
op.f("ix_log_observation_version_operation_type"),
table_name="log_observation_version",
)
op.drop_index(
op.f("ix_log_observation_version_end_transaction_id"),
table_name="log_observation_version",
)
op.drop_table("log_observation_version")
op.drop_table("log_observation")
# log_medical
op.drop_index(
op.f("ix_log_medical_version_transaction_id"), table_name="log_medical_version"
)
op.drop_index(
"ix_log_medical_version_pk_validity", table_name="log_medical_version"
)
op.drop_index(
"ix_log_medical_version_pk_transaction_id", table_name="log_medical_version"
)
op.drop_index(
op.f("ix_log_medical_version_operation_type"), table_name="log_medical_version"
)
op.drop_index(
op.f("ix_log_medical_version_end_transaction_id"),
table_name="log_medical_version",
)
op.drop_table("log_medical_version")
op.drop_table("log_medical")
# log_harvest
op.drop_index(
op.f("ix_log_harvest_version_transaction_id"), table_name="log_harvest_version"
)
op.drop_index(
"ix_log_harvest_version_pk_validity", table_name="log_harvest_version"
)
op.drop_index(
"ix_log_harvest_version_pk_transaction_id", table_name="log_harvest_version"
)
op.drop_index(
op.f("ix_log_harvest_version_operation_type"), table_name="log_harvest_version"
)
op.drop_index(
op.f("ix_log_harvest_version_end_transaction_id"),
table_name="log_harvest_version",
)
op.drop_table("log_harvest_version")
op.drop_table("log_harvest")
# log_asset
op.drop_index(
op.f("ix_log_asset_version_transaction_id"), table_name="log_asset_version"
)
op.drop_index("ix_log_asset_version_pk_validity", table_name="log_asset_version")
op.drop_index(
"ix_log_asset_version_pk_transaction_id", table_name="log_asset_version"
)
op.drop_index(
op.f("ix_log_asset_version_operation_type"), table_name="log_asset_version"
)
op.drop_index(
op.f("ix_log_asset_version_end_transaction_id"), table_name="log_asset_version"
)
op.drop_table("log_asset_version")
op.drop_table("log_asset")
# asset_plant_plant_type
op.drop_index(
op.f("ix_asset_plant_plant_type_version_transaction_id"),
table_name="asset_plant_plant_type_version",
)
op.drop_index(
"ix_asset_plant_plant_type_version_pk_validity",
table_name="asset_plant_plant_type_version",
)
op.drop_index(
"ix_asset_plant_plant_type_version_pk_transaction_id",
table_name="asset_plant_plant_type_version",
)
op.drop_index(
op.f("ix_asset_plant_plant_type_version_operation_type"),
table_name="asset_plant_plant_type_version",
)
op.drop_index(
op.f("ix_asset_plant_plant_type_version_end_transaction_id"),
table_name="asset_plant_plant_type_version",
)
op.drop_table("asset_plant_plant_type_version")
op.drop_table("asset_plant_plant_type")
# asset_plant
op.drop_index(
op.f("ix_asset_plant_version_transaction_id"), table_name="asset_plant_version"
)
op.drop_index(
"ix_asset_plant_version_pk_validity", table_name="asset_plant_version"
)
op.drop_index(
"ix_asset_plant_version_pk_transaction_id", table_name="asset_plant_version"
)
op.drop_index(
op.f("ix_asset_plant_version_operation_type"), table_name="asset_plant_version"
)
op.drop_index(
op.f("ix_asset_plant_version_end_transaction_id"),
table_name="asset_plant_version",
)
op.drop_table("asset_plant_version")
op.drop_table("asset_plant")
# plant_type
op.drop_index(
op.f("ix_plant_type_version_transaction_id"), table_name="plant_type_version"
)
op.drop_index("ix_plant_type_version_pk_validity", table_name="plant_type_version")
op.drop_index(
"ix_plant_type_version_pk_transaction_id", table_name="plant_type_version"
)
op.drop_index(
op.f("ix_plant_type_version_operation_type"), table_name="plant_type_version"
)
op.drop_index(
op.f("ix_plant_type_version_end_transaction_id"),
table_name="plant_type_version",
)
op.drop_table("plant_type_version")
op.drop_table("plant_type")

View file

@ -0,0 +1,114 @@
"""add Asset.owners
Revision ID: 12de43facb95
Revises: 85d4851e8292
Create Date: 2026-03-02 19:03:35.511398
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "12de43facb95"
down_revision: Union[str, None] = "85d4851e8292"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_owner
op.create_table(
"asset_owner",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"], ["asset.uuid"], name=op.f("fk_asset_owner_asset_uuid_asset")
),
sa.ForeignKeyConstraint(
["user_uuid"], ["user.uuid"], name=op.f("fk_asset_owner_user_uuid_user")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_owner")),
)
op.create_table(
"asset_owner_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_owner_version")
),
)
op.create_index(
op.f("ix_asset_owner_version_end_transaction_id"),
"asset_owner_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_owner_version_operation_type"),
"asset_owner_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_owner_version_pk_transaction_id",
"asset_owner_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_owner_version_pk_validity",
"asset_owner_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_owner_version_transaction_id"),
"asset_owner_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_owner
op.drop_index(
op.f("ix_asset_owner_version_transaction_id"), table_name="asset_owner_version"
)
op.drop_index(
"ix_asset_owner_version_pk_validity", table_name="asset_owner_version"
)
op.drop_index(
"ix_asset_owner_version_pk_transaction_id", table_name="asset_owner_version"
)
op.drop_index(
op.f("ix_asset_owner_version_operation_type"), table_name="asset_owner_version"
)
op.drop_index(
op.f("ix_asset_owner_version_end_transaction_id"),
table_name="asset_owner_version",
)
op.drop_table("asset_owner_version")
op.drop_table("asset_owner")

View file

@ -0,0 +1,127 @@
"""add Animals
Revision ID: 1b2d3224e5dc
Revises: 4dbba8aeb1e5
Create Date: 2026-02-13 11:55:19.564221
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "1b2d3224e5dc"
down_revision: Union[str, None] = "4dbba8aeb1e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal
op.create_table(
"animal",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("animal_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("birthdate", sa.DateTime(), nullable=True),
sa.Column("sex", sa.String(length=1), nullable=True),
sa.Column("is_sterile", sa.Boolean(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("image_url", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["animal_type_uuid"],
["animal_type.uuid"],
name=op.f("fk_animal_animal_type_uuid_animal_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_animal_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_animal_farmos_uuid")),
)
op.create_table(
"animal_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"animal_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("sex", sa.String(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_animal_version")
),
)
op.create_index(
op.f("ix_animal_version_end_transaction_id"),
"animal_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_operation_type"),
"animal_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_animal_version_pk_transaction_id",
"animal_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_animal_version_pk_validity",
"animal_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_transaction_id"),
"animal_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# animal
op.drop_index(op.f("ix_animal_version_transaction_id"), table_name="animal_version")
op.drop_index("ix_animal_version_pk_validity", table_name="animal_version")
op.drop_index("ix_animal_version_pk_transaction_id", table_name="animal_version")
op.drop_index(op.f("ix_animal_version_operation_type"), table_name="animal_version")
op.drop_index(
op.f("ix_animal_version_end_transaction_id"), table_name="animal_version"
)
op.drop_table("animal_version")
op.drop_table("animal")

View file

@ -0,0 +1,119 @@
"""add Quantity Types
Revision ID: 1f98d27cabeb
Revises: ea88e72a5fa5
Create Date: 2026-02-18 21:03:52.245619
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "1f98d27cabeb"
down_revision: Union[str, None] = "ea88e72a5fa5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# quantity_type
op.create_table(
"quantity_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_type_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_quantity_type_name")),
)
op.create_table(
"quantity_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"description", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"drupal_id", sa.String(length=50), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_quantity_type_version")
),
)
op.create_index(
op.f("ix_quantity_type_version_end_transaction_id"),
"quantity_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_type_version_operation_type"),
"quantity_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_quantity_type_version_pk_transaction_id",
"quantity_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_quantity_type_version_pk_validity",
"quantity_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_type_version_transaction_id"),
"quantity_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# quantity_type
op.drop_index(
op.f("ix_quantity_type_version_transaction_id"),
table_name="quantity_type_version",
)
op.drop_index(
"ix_quantity_type_version_pk_validity", table_name="quantity_type_version"
)
op.drop_index(
"ix_quantity_type_version_pk_transaction_id", table_name="quantity_type_version"
)
op.drop_index(
op.f("ix_quantity_type_version_operation_type"),
table_name="quantity_type_version",
)
op.drop_index(
op.f("ix_quantity_type_version_end_transaction_id"),
table_name="quantity_type_version",
)
op.drop_table("quantity_type_version")
op.drop_table("quantity_type")

View file

@ -0,0 +1,41 @@
"""add animal thumbnail url
Revision ID: 2a49127e974b
Revises: 8898184c5c75
Create Date: 2026-02-14 19:41:22.039343
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "2a49127e974b"
down_revision: Union[str, None] = "8898184c5c75"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal
op.add_column(
"animal", sa.Column("thumbnail_url", sa.String(length=255), nullable=True)
)
op.add_column(
"animal_version",
sa.Column(
"thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
),
)
def downgrade() -> None:
# animal
op.drop_column("animal_version", "thumbnail_url")
op.drop_column("animal", "thumbnail_url")

View file

@ -0,0 +1,116 @@
"""add Animal Types
Revision ID: 2b6385d0fa17
Revises:
Create Date: 2026-02-08 14:55:42.236918
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "2b6385d0fa17"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = ("wuttafarm",)
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal_type
op.create_table(
"animal_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("changed", sa.DateTime(), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_animal_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_animal_type_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_animal_type_name")),
)
op.create_table(
"animal_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"description", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_animal_type_version")
),
)
op.create_index(
op.f("ix_animal_type_version_end_transaction_id"),
"animal_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_type_version_operation_type"),
"animal_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_animal_type_version_pk_transaction_id",
"animal_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_animal_type_version_pk_validity",
"animal_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_type_version_transaction_id"),
"animal_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# animal_type
op.drop_index(
op.f("ix_animal_type_version_transaction_id"), table_name="animal_type_version"
)
op.drop_index(
"ix_animal_type_version_pk_validity", table_name="animal_type_version"
)
op.drop_index(
"ix_animal_type_version_pk_transaction_id", table_name="animal_type_version"
)
op.drop_index(
op.f("ix_animal_type_version_operation_type"), table_name="animal_type_version"
)
op.drop_index(
op.f("ix_animal_type_version_end_transaction_id"),
table_name="animal_type_version",
)
op.drop_table("animal_type_version")
op.drop_table("animal_type")

View file

@ -0,0 +1,236 @@
"""use shared base for Structure Assets
Revision ID: 34ec51d80f52
Revises: d882682c82f9
Create Date: 2026-02-15 13:19:18.814523
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "34ec51d80f52"
down_revision: Union[str, None] = "d882682c82f9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_structure
op.create_table(
"asset_structure",
sa.Column("structure_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["structure_type_uuid"],
["structure_type.uuid"],
name=op.f("fk_asset_structure_structure_type_uuid_structure_type"),
),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_structure_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_structure")),
)
op.create_table(
"asset_structure_version",
sa.Column(
"structure_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_structure_version")
),
)
op.create_index(
op.f("ix_asset_structure_version_end_transaction_id"),
"asset_structure_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_structure_version_operation_type"),
"asset_structure_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_structure_version_pk_transaction_id",
"asset_structure_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_structure_version_pk_validity",
"asset_structure_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_structure_version_transaction_id"),
"asset_structure_version",
["transaction_id"],
unique=False,
)
# structure
op.drop_index(
op.f("ix_structure_version_end_transaction_id"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_operation_type"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_pk_transaction_id"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_pk_validity"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_transaction_id"), table_name="structure_version"
)
op.drop_table("structure_version")
op.drop_table("structure")
def downgrade() -> None:
# structure
op.create_table(
"structure",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column(
"structure_type_uuid", sa.UUID(), autoincrement=False, nullable=False
),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.ForeignKeyConstraint(
["structure_type_uuid"],
["structure_type.uuid"],
name=op.f("fk_structure_structure_type_uuid_structure_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_structure_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_structure_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"name",
name=op.f("uq_structure_name"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"structure_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("structure_type_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_structure_version")
),
)
op.create_index(
op.f("ix_structure_version_transaction_id"),
"structure_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_pk_validity"),
"structure_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_pk_transaction_id"),
"structure_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_structure_version_operation_type"),
"structure_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_end_transaction_id"),
"structure_version",
["end_transaction_id"],
unique=False,
)
# asset_structure
op.drop_index(
op.f("ix_asset_structure_version_transaction_id"),
table_name="asset_structure_version",
)
op.drop_index(
"ix_asset_structure_version_pk_validity", table_name="asset_structure_version"
)
op.drop_index(
"ix_asset_structure_version_pk_transaction_id",
table_name="asset_structure_version",
)
op.drop_index(
op.f("ix_asset_structure_version_operation_type"),
table_name="asset_structure_version",
)
op.drop_index(
op.f("ix_asset_structure_version_end_transaction_id"),
table_name="asset_structure_version",
)
op.drop_table("asset_structure_version")
op.drop_table("asset_structure")

View file

@ -0,0 +1,118 @@
"""add LogLocation
Revision ID: 3bef7d380a38
Revises: f3c7e273bfa3
Create Date: 2026-02-28 20:41:56.051847
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "3bef7d380a38"
down_revision: Union[str, None] = "f3c7e273bfa3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_location
op.create_table(
"log_location",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"],
["asset.uuid"],
name=op.f("fk_log_location_asset_uuid_asset"),
),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_location_log_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_location")),
)
op.create_table(
"log_location_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_location_version")
),
)
op.create_index(
op.f("ix_log_location_version_end_transaction_id"),
"log_location_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_location_version_operation_type"),
"log_location_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_location_version_pk_transaction_id",
"log_location_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_location_version_pk_validity",
"log_location_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_location_version_transaction_id"),
"log_location_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_location
op.drop_index(
op.f("ix_log_location_version_transaction_id"),
table_name="log_location_version",
)
op.drop_index(
"ix_log_location_version_pk_validity", table_name="log_location_version"
)
op.drop_index(
"ix_log_location_version_pk_transaction_id", table_name="log_location_version"
)
op.drop_index(
op.f("ix_log_location_version_operation_type"),
table_name="log_location_version",
)
op.drop_index(
op.f("ix_log_location_version_end_transaction_id"),
table_name="log_location_version",
)
op.drop_table("log_location_version")
op.drop_table("log_location")

View file

@ -0,0 +1,118 @@
"""add Activity Logs
Revision ID: 3e2ef02bf264
Revises: 92b813360b99
Create Date: 2026-02-13 14:36:47.191922
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "3e2ef02bf264"
down_revision: Union[str, None] = "92b813360b99"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_activity
op.create_table(
"log_activity",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("message", sa.String(length=255), nullable=False),
sa.Column("timestamp", sa.DateTime(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_activity")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_log_activity_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_activity_farmos_uuid")),
)
op.create_table(
"log_activity_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("message", sa.String(length=255), autoincrement=False, nullable=True),
sa.Column("timestamp", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("status", sa.String(length=20), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_activity_version")
),
)
op.create_index(
op.f("ix_log_activity_version_end_transaction_id"),
"log_activity_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_activity_version_operation_type"),
"log_activity_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_activity_version_pk_transaction_id",
"log_activity_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_activity_version_pk_validity",
"log_activity_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_activity_version_transaction_id"),
"log_activity_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_activity
op.drop_index(
op.f("ix_log_activity_version_transaction_id"),
table_name="log_activity_version",
)
op.drop_index(
"ix_log_activity_version_pk_validity", table_name="log_activity_version"
)
op.drop_index(
"ix_log_activity_version_pk_transaction_id", table_name="log_activity_version"
)
op.drop_index(
op.f("ix_log_activity_version_operation_type"),
table_name="log_activity_version",
)
op.drop_index(
op.f("ix_log_activity_version_end_transaction_id"),
table_name="log_activity_version",
)
op.drop_table("log_activity_version")
op.drop_table("log_activity")

View file

@ -0,0 +1,37 @@
"""remove unique for animal_type.name
Revision ID: 45c7718d2ed2
Revises: 5b6c87d8cddf
Create Date: 2026-02-27 16:53:59.310342
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "45c7718d2ed2"
down_revision: Union[str, None] = "5b6c87d8cddf"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal_type
op.drop_constraint(op.f("uq_animal_type_name"), "animal_type", type_="unique")
def downgrade() -> None:
# animal_type
op.create_unique_constraint(
op.f("uq_animal_type_name"),
"animal_type",
["name"],
postgresql_nulls_not_distinct=False,
)

View file

@ -0,0 +1,108 @@
"""add LogOwner
Revision ID: 47d0ebd84554
Revises: 45c7718d2ed2
Create Date: 2026-02-28 19:18:49.122090
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "47d0ebd84554"
down_revision: Union[str, None] = "45c7718d2ed2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_owner
op.create_table(
"log_owner",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_owner_log_uuid_log")
),
sa.ForeignKeyConstraint(
["user_uuid"], ["user.uuid"], name=op.f("fk_log_owner_user_uuid_user")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_owner")),
)
op.create_table(
"log_owner_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_owner_version")
),
)
op.create_index(
op.f("ix_log_owner_version_end_transaction_id"),
"log_owner_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_owner_version_operation_type"),
"log_owner_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_owner_version_pk_transaction_id",
"log_owner_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_owner_version_pk_validity",
"log_owner_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_owner_version_transaction_id"),
"log_owner_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_owner
op.drop_index(
op.f("ix_log_owner_version_transaction_id"), table_name="log_owner_version"
)
op.drop_index("ix_log_owner_version_pk_validity", table_name="log_owner_version")
op.drop_index(
"ix_log_owner_version_pk_transaction_id", table_name="log_owner_version"
)
op.drop_index(
op.f("ix_log_owner_version_operation_type"), table_name="log_owner_version"
)
op.drop_index(
op.f("ix_log_owner_version_end_transaction_id"), table_name="log_owner_version"
)
op.drop_table("log_owner_version")
op.drop_table("log_owner")

View file

@ -0,0 +1,132 @@
"""add Structures
Revision ID: 4dbba8aeb1e5
Revises: e416b96467fc
Create Date: 2026-02-13 10:17:15.179202
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "4dbba8aeb1e5"
down_revision: Union[str, None] = "e416b96467fc"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# structure
op.create_table(
"structure",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("structure_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("is_location", sa.Boolean(), nullable=False),
sa.Column("is_fixed", sa.Boolean(), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("image_url", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["structure_type_uuid"],
["structure_type.uuid"],
name=op.f("fk_structure_structure_type_uuid_structure_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_structure_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_structure_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_structure_name")),
)
op.create_table(
"structure_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column(
"structure_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_structure_version")
),
)
op.create_index(
op.f("ix_structure_version_end_transaction_id"),
"structure_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_operation_type"),
"structure_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_structure_version_pk_transaction_id",
"structure_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_structure_version_pk_validity",
"structure_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_transaction_id"),
"structure_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# structure
op.drop_index(
op.f("ix_structure_version_transaction_id"), table_name="structure_version"
)
op.drop_index("ix_structure_version_pk_validity", table_name="structure_version")
op.drop_index(
"ix_structure_version_pk_transaction_id", table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_operation_type"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_end_transaction_id"), table_name="structure_version"
)
op.drop_table("structure_version")
op.drop_table("structure")

View file

@ -0,0 +1,125 @@
"""add LandAssetParent model
Revision ID: 554e6168c339
Revises: 8cc1565d38e7
Create Date: 2026-02-14 20:41:24.859064
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "554e6168c339"
down_revision: Union[str, None] = "8cc1565d38e7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# land_asset_parent
op.create_table(
"land_asset_parent",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("land_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("parent_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["land_asset_uuid"],
["land_asset.uuid"],
name=op.f("fk_land_asset_parent_land_asset_uuid_land_asset"),
),
sa.ForeignKeyConstraint(
["parent_asset_uuid"],
["land_asset.uuid"],
name=op.f("fk_land_asset_parent_parent_asset_uuid_land_asset"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset_parent")),
)
op.create_table(
"land_asset_parent_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"land_asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"parent_asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_land_asset_parent_version")
),
)
op.create_index(
op.f("ix_land_asset_parent_version_end_transaction_id"),
"land_asset_parent_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_parent_version_operation_type"),
"land_asset_parent_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_land_asset_parent_version_pk_transaction_id",
"land_asset_parent_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_land_asset_parent_version_pk_validity",
"land_asset_parent_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_parent_version_transaction_id"),
"land_asset_parent_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# land_asset_parent
op.drop_index(
op.f("ix_land_asset_parent_version_transaction_id"),
table_name="land_asset_parent_version",
)
op.drop_index(
"ix_land_asset_parent_version_pk_validity",
table_name="land_asset_parent_version",
)
op.drop_index(
"ix_land_asset_parent_version_pk_transaction_id",
table_name="land_asset_parent_version",
)
op.drop_index(
op.f("ix_land_asset_parent_version_operation_type"),
table_name="land_asset_parent_version",
)
op.drop_index(
op.f("ix_land_asset_parent_version_end_transaction_id"),
table_name="land_asset_parent_version",
)
op.drop_table("land_asset_parent_version")
op.drop_table("land_asset_parent")

View file

@ -0,0 +1,293 @@
"""add Standard Quantities
Revision ID: 5b6c87d8cddf
Revises: 1f98d27cabeb
Create Date: 2026-02-19 15:42:19.691148
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "5b6c87d8cddf"
down_revision: Union[str, None] = "1f98d27cabeb"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# measure
op.create_table(
"measure",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("drupal_id", sa.String(length=20), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_measure")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_measure_drupal_id")),
sa.UniqueConstraint("name", name=op.f("uq_measure_name")),
)
op.create_table(
"measure_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"drupal_id", sa.String(length=20), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_measure_version")
),
)
op.create_index(
op.f("ix_measure_version_end_transaction_id"),
"measure_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_measure_version_operation_type"),
"measure_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_measure_version_pk_transaction_id",
"measure_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_measure_version_pk_validity",
"measure_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_measure_version_transaction_id"),
"measure_version",
["transaction_id"],
unique=False,
)
# quantity
op.create_table(
"quantity",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("quantity_type_id", sa.String(length=50), nullable=False),
sa.Column("measure_id", sa.String(length=20), nullable=False),
sa.Column("value_numerator", sa.Integer(), nullable=False),
sa.Column("value_denominator", sa.Integer(), nullable=False),
sa.Column("units_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("label", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["measure_id"],
["measure.drupal_id"],
name=op.f("fk_quantity_measure_id_measure"),
),
sa.ForeignKeyConstraint(
["quantity_type_id"],
["quantity_type.drupal_id"],
name=op.f("fk_quantity_quantity_type_id_quantity_type"),
),
sa.ForeignKeyConstraint(
["units_uuid"], ["unit.uuid"], name=op.f("fk_quantity_units_uuid_unit")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_farmos_uuid")),
)
op.create_table(
"quantity_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"quantity_type_id", sa.String(length=50), autoincrement=False, nullable=True
),
sa.Column(
"measure_id", sa.String(length=20), autoincrement=False, nullable=True
),
sa.Column("value_numerator", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"value_denominator", sa.Integer(), autoincrement=False, nullable=True
),
sa.Column(
"units_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("label", sa.String(length=255), autoincrement=False, nullable=True),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_quantity_version")
),
)
op.create_index(
op.f("ix_quantity_version_end_transaction_id"),
"quantity_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_version_operation_type"),
"quantity_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_quantity_version_pk_transaction_id",
"quantity_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_quantity_version_pk_validity",
"quantity_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_version_transaction_id"),
"quantity_version",
["transaction_id"],
unique=False,
)
# quantity_standard
op.create_table(
"quantity_standard",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["quantity.uuid"], name=op.f("fk_quantity_standard_uuid_quantity")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_standard")),
)
op.create_table(
"quantity_standard_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_quantity_standard_version")
),
)
op.create_index(
op.f("ix_quantity_standard_version_end_transaction_id"),
"quantity_standard_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_standard_version_operation_type"),
"quantity_standard_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_quantity_standard_version_pk_transaction_id",
"quantity_standard_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_quantity_standard_version_pk_validity",
"quantity_standard_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_standard_version_transaction_id"),
"quantity_standard_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# quantity_standard
op.drop_index(
op.f("ix_quantity_standard_version_transaction_id"),
table_name="quantity_standard_version",
)
op.drop_index(
"ix_quantity_standard_version_pk_validity",
table_name="quantity_standard_version",
)
op.drop_index(
"ix_quantity_standard_version_pk_transaction_id",
table_name="quantity_standard_version",
)
op.drop_index(
op.f("ix_quantity_standard_version_operation_type"),
table_name="quantity_standard_version",
)
op.drop_index(
op.f("ix_quantity_standard_version_end_transaction_id"),
table_name="quantity_standard_version",
)
op.drop_table("quantity_standard_version")
op.drop_table("quantity_standard")
# quantity
op.drop_index(
op.f("ix_quantity_version_transaction_id"), table_name="quantity_version"
)
op.drop_index("ix_quantity_version_pk_validity", table_name="quantity_version")
op.drop_index(
"ix_quantity_version_pk_transaction_id", table_name="quantity_version"
)
op.drop_index(
op.f("ix_quantity_version_operation_type"), table_name="quantity_version"
)
op.drop_index(
op.f("ix_quantity_version_end_transaction_id"), table_name="quantity_version"
)
op.drop_table("quantity_version")
op.drop_table("quantity")
# measure
op.drop_index(
op.f("ix_measure_version_transaction_id"), table_name="measure_version"
)
op.drop_index("ix_measure_version_pk_validity", table_name="measure_version")
op.drop_index("ix_measure_version_pk_transaction_id", table_name="measure_version")
op.drop_index(
op.f("ix_measure_version_operation_type"), table_name="measure_version"
)
op.drop_index(
op.f("ix_measure_version_end_transaction_id"), table_name="measure_version"
)
op.drop_table("measure_version")
op.drop_table("measure")

View file

@ -0,0 +1,39 @@
"""remove unwanted unique constraint
Revision ID: 5f474125a80e
Revises: 0771322957bd
Create Date: 2026-03-04 12:03:16.034291
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "5f474125a80e"
down_revision: Union[str, None] = "0771322957bd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_land
op.drop_constraint(
op.f("uq_asset_land_land_type_uuid"), "asset_land", type_="unique"
)
def downgrade() -> None:
# asset_land
op.create_unique_constraint(
op.f("uq_asset_land_land_type_uuid"),
"asset_land",
["land_type_uuid"],
postgresql_nulls_not_distinct=False,
)

View file

@ -0,0 +1,112 @@
"""add WuttaFarmUser
Revision ID: 6c56bcd1c028
Revises: 2b6385d0fa17
Create Date: 2026-02-09 20:46:20.995903
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "6c56bcd1c028"
down_revision: Union[str, None] = "2b6385d0fa17"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# wuttafarm_user
op.create_table(
"wuttafarm_user",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["uuid"], ["user.uuid"], name=op.f("fk_wuttafarm_user_uuid_user")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_wuttafarm_user")),
)
op.create_table(
"wuttafarm_user_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_wuttafarm_user_version")
),
)
op.create_index(
op.f("ix_wuttafarm_user_version_end_transaction_id"),
"wuttafarm_user_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_wuttafarm_user_version_operation_type"),
"wuttafarm_user_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_wuttafarm_user_version_pk_transaction_id",
"wuttafarm_user_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_wuttafarm_user_version_pk_validity",
"wuttafarm_user_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_wuttafarm_user_version_transaction_id"),
"wuttafarm_user_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# wuttafarm_user
op.drop_index(
op.f("ix_wuttafarm_user_version_transaction_id"),
table_name="wuttafarm_user_version",
)
op.drop_index(
"ix_wuttafarm_user_version_pk_validity", table_name="wuttafarm_user_version"
)
op.drop_index(
"ix_wuttafarm_user_version_pk_transaction_id",
table_name="wuttafarm_user_version",
)
op.drop_index(
op.f("ix_wuttafarm_user_version_operation_type"),
table_name="wuttafarm_user_version",
)
op.drop_index(
op.f("ix_wuttafarm_user_version_end_transaction_id"),
table_name="wuttafarm_user_version",
)
op.drop_table("wuttafarm_user_version")
op.drop_table("wuttafarm_user")

View file

@ -0,0 +1,111 @@
"""add LogGroup
Revision ID: 74d32b4ec210
Revises: 3bef7d380a38
Create Date: 2026-02-28 21:35:24.125784
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "74d32b4ec210"
down_revision: Union[str, None] = "3bef7d380a38"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_group
op.create_table(
"log_group",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"], ["asset.uuid"], name=op.f("fk_log_group_asset_uuid_asset")
),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_group_log_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_group")),
)
op.create_table(
"log_group_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_group_version")
),
)
op.create_index(
op.f("ix_log_group_version_end_transaction_id"),
"log_group_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_group_version_operation_type"),
"log_group_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_group_version_pk_transaction_id",
"log_group_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_group_version_pk_validity",
"log_group_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_group_version_transaction_id"),
"log_group_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_group
op.drop_index(
op.f("ix_log_group_version_transaction_id"), table_name="log_group_version"
)
op.drop_index("ix_log_group_version_pk_validity", table_name="log_group_version")
op.drop_index(
"ix_log_group_version_pk_transaction_id", table_name="log_group_version"
)
op.drop_index(
op.f("ix_log_group_version_operation_type"), table_name="log_group_version"
)
op.drop_index(
op.f("ix_log_group_version_end_transaction_id"), table_name="log_group_version"
)
op.drop_table("log_group_version")
op.drop_table("log_group")

View file

@ -0,0 +1,52 @@
"""add produces_eggs via EggMixin
Revision ID: 82a03f4ef1a4
Revises: 11e0e46f48a6
Create Date: 2026-02-18 18:45:36.015144
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "82a03f4ef1a4"
down_revision: Union[str, None] = "11e0e46f48a6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_animal
op.add_column(
"asset_animal", sa.Column("produces_eggs", sa.Boolean(), nullable=True)
)
op.add_column(
"asset_animal_version",
sa.Column("produces_eggs", sa.Boolean(), autoincrement=False, nullable=True),
)
# asset_group
op.add_column(
"asset_group", sa.Column("produces_eggs", sa.Boolean(), nullable=True)
)
op.add_column(
"asset_group_version",
sa.Column("produces_eggs", sa.Boolean(), autoincrement=False, nullable=True),
)
def downgrade() -> None:
# asset_group
op.drop_column("asset_group_version", "produces_eggs")
op.drop_column("asset_group", "produces_eggs")
# asset_animal
op.drop_column("asset_animal_version", "produces_eggs")
op.drop_column("asset_animal", "produces_eggs")

View file

@ -0,0 +1,37 @@
"""add Log.quick
Revision ID: 85d4851e8292
Revises: d459db991404
Create Date: 2026-03-02 18:42:56.070281
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "85d4851e8292"
down_revision: Union[str, None] = "d459db991404"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log
op.add_column("log", sa.Column("quick", sa.String(length=20), nullable=True))
op.add_column(
"log_version",
sa.Column("quick", sa.String(length=20), autoincrement=False, nullable=True),
)
def downgrade() -> None:
# log
op.drop_column("log_version", "quick")
op.drop_column("log", "quick")

View file

@ -0,0 +1,250 @@
"""convert active to archived
Revision ID: 8898184c5c75
Revises: 3e2ef02bf264
Create Date: 2026-02-14 18:41:23.042951
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "8898184c5c75"
down_revision: Union[str, None] = "3e2ef02bf264"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal
op.alter_column("animal", "active", new_column_name="archived")
animal = sa.sql.table(
"animal",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(animal.select())
for row in cursor.fetchall():
op.get_bind().execute(
animal.update()
.where(animal.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
op.alter_column("animal_version", "active", new_column_name="archived")
animal_version = sa.sql.table(
"animal_version",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(animal_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
animal_version.update()
.where(animal_version.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
# group
op.alter_column("group", "active", new_column_name="archived")
group = sa.sql.table(
"group",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(group.select())
for row in cursor.fetchall():
op.get_bind().execute(
group.update()
.where(group.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
op.alter_column("group_version", "active", new_column_name="archived")
group_version = sa.sql.table(
"group_version",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(group_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
group_version.update()
.where(group_version.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
# land_asset
op.alter_column("land_asset", "active", new_column_name="archived")
land_asset = sa.sql.table(
"land_asset",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(land_asset.select())
for row in cursor.fetchall():
op.get_bind().execute(
land_asset.update()
.where(land_asset.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
op.alter_column("land_asset_version", "active", new_column_name="archived")
land_asset_version = sa.sql.table(
"land_asset_version",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(land_asset_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
land_asset_version.update()
.where(land_asset_version.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
# structure
op.alter_column("structure", "active", new_column_name="archived")
structure = sa.sql.table(
"structure",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(structure.select())
for row in cursor.fetchall():
op.get_bind().execute(
structure.update()
.where(structure.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
op.alter_column("structure_version", "active", new_column_name="archived")
structure_version = sa.sql.table(
"structure_version",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(structure_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
structure_version.update()
.where(structure_version.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
def downgrade() -> None:
# structure
op.alter_column("structure", "archived", new_column_name="active")
structure = sa.sql.table(
"structure",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(structure.select())
for row in cursor.fetchall():
op.get_bind().execute(
structure.update()
.where(structure.c.uuid == row.uuid)
.values({"active": not row.active})
)
op.alter_column("structure_version", "archived", new_column_name="active")
structure_version = sa.sql.table(
"structure_version",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(structure_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
structure_version.update()
.where(structure_version.c.uuid == row.uuid)
.values({"active": not row.active})
)
# land_asset
op.alter_column("land_asset", "archived", new_column_name="active")
land_asset = sa.sql.table(
"land_asset",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(land_asset.select())
for row in cursor.fetchall():
op.get_bind().execute(
land_asset.update()
.where(land_asset.c.uuid == row.uuid)
.values({"active": not row.active})
)
op.alter_column("land_asset_version", "archived", new_column_name="active")
land_asset_version = sa.sql.table(
"land_asset_version",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(land_asset_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
land_asset_version.update()
.where(land_asset_version.c.uuid == row.uuid)
.values({"active": not row.active})
)
# group
op.alter_column("group", "archived", new_column_name="active")
group = sa.sql.table(
"group",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(group.select())
for row in cursor.fetchall():
op.get_bind().execute(
group.update()
.where(group.c.uuid == row.uuid)
.values({"active": not row.active})
)
op.alter_column("group_version", "archived", new_column_name="active")
group_version = sa.sql.table(
"group_version",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(group_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
group_version.update()
.where(group_version.c.uuid == row.uuid)
.values({"active": not row.active})
)
# animal
op.alter_column("animal", "archived", new_column_name="active")
animal = sa.sql.table(
"animal",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(animal.select())
for row in cursor.fetchall():
op.get_bind().execute(
animal.update()
.where(animal.c.uuid == row.uuid)
.values({"active": not row.active})
)
op.alter_column("animal_version", "archived", new_column_name="active")
animal_version = sa.sql.table(
"animal_version",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(animal_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
animal_version.update()
.where(animal_version.c.uuid == row.uuid)
.values({"active": not row.active})
)

View file

@ -0,0 +1,41 @@
"""add structure thumbnail url
Revision ID: 8cc1565d38e7
Revises: 2a49127e974b
Create Date: 2026-02-14 20:07:33.913573
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "8cc1565d38e7"
down_revision: Union[str, None] = "2a49127e974b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# structure
op.add_column(
"structure", sa.Column("thumbnail_url", sa.String(length=255), nullable=True)
)
op.add_column(
"structure_version",
sa.Column(
"thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
),
)
def downgrade() -> None:
# structure
op.drop_column("structure_version", "thumbnail_url")
op.drop_column("structure", "thumbnail_url")

View file

@ -0,0 +1,110 @@
"""add Groups
Revision ID: 92b813360b99
Revises: 1b2d3224e5dc
Create Date: 2026-02-13 13:09:48.718064
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "92b813360b99"
down_revision: Union[str, None] = "1b2d3224e5dc"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# group
op.create_table(
"group",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("is_location", sa.Boolean(), nullable=False),
sa.Column("is_fixed", sa.Boolean(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_group")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_group_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_group_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_group_name")),
)
op.create_table(
"group_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_group_version")
),
)
op.create_index(
op.f("ix_group_version_end_transaction_id"),
"group_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_group_version_operation_type"),
"group_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_group_version_pk_transaction_id",
"group_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_group_version_pk_validity",
"group_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_group_version_transaction_id"),
"group_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# group
op.drop_index(op.f("ix_group_version_transaction_id"), table_name="group_version")
op.drop_index("ix_group_version_pk_validity", table_name="group_version")
op.drop_index("ix_group_version_pk_transaction_id", table_name="group_version")
op.drop_index(op.f("ix_group_version_operation_type"), table_name="group_version")
op.drop_index(
op.f("ix_group_version_end_transaction_id"), table_name="group_version"
)
op.drop_table("group_version")
op.drop_table("group")

View file

@ -0,0 +1,118 @@
"""add LogQuantity
Revision ID: 9e875e5cbdc1
Revises: 74d32b4ec210
Create Date: 2026-02-28 21:55:31.876087
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "9e875e5cbdc1"
down_revision: Union[str, None] = "74d32b4ec210"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_quantity
op.create_table(
"log_quantity",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("quantity_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_quantity_log_uuid_log")
),
sa.ForeignKeyConstraint(
["quantity_uuid"],
["quantity.uuid"],
name=op.f("fk_log_quantity_quantity_uuid_quantity"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_quantity")),
)
op.create_table(
"log_quantity_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"quantity_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_quantity_version")
),
)
op.create_index(
op.f("ix_log_quantity_version_end_transaction_id"),
"log_quantity_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_quantity_version_operation_type"),
"log_quantity_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_quantity_version_pk_transaction_id",
"log_quantity_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_quantity_version_pk_validity",
"log_quantity_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_quantity_version_transaction_id"),
"log_quantity_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_quantity
op.drop_index(
op.f("ix_log_quantity_version_transaction_id"),
table_name="log_quantity_version",
)
op.drop_index(
"ix_log_quantity_version_pk_validity", table_name="log_quantity_version"
)
op.drop_index(
"ix_log_quantity_version_pk_transaction_id", table_name="log_quantity_version"
)
op.drop_index(
op.f("ix_log_quantity_version_operation_type"),
table_name="log_quantity_version",
)
op.drop_index(
op.f("ix_log_quantity_version_end_transaction_id"),
table_name="log_quantity_version",
)
op.drop_table("log_quantity_version")
op.drop_table("log_quantity")

View file

@ -0,0 +1,110 @@
"""add Land Types
Revision ID: 9f2243df9566
Revises: cf3f8f46d8bc
Create Date: 2026-02-10 19:10:02.851756
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "9f2243df9566"
down_revision: Union[str, None] = "cf3f8f46d8bc"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# land_type
op.create_table(
"land_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_land_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_land_type_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_land_type_name")),
)
op.create_table(
"land_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"drupal_id", sa.String(length=50), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_land_type_version")
),
)
op.create_index(
op.f("ix_land_type_version_end_transaction_id"),
"land_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_type_version_operation_type"),
"land_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_land_type_version_pk_transaction_id",
"land_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_land_type_version_pk_validity",
"land_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_type_version_transaction_id"),
"land_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# land_type
op.drop_index(
op.f("ix_land_type_version_transaction_id"), table_name="land_type_version"
)
op.drop_index("ix_land_type_version_pk_validity", table_name="land_type_version")
op.drop_index(
"ix_land_type_version_pk_transaction_id", table_name="land_type_version"
)
op.drop_index(
op.f("ix_land_type_version_operation_type"), table_name="land_type_version"
)
op.drop_index(
op.f("ix_land_type_version_end_transaction_id"), table_name="land_type_version"
)
op.drop_table("land_type_version")
op.drop_table("land_type")

View file

@ -0,0 +1,194 @@
"""use shared base for Group Assets
Revision ID: aecfd9175624
Revises: 34ec51d80f52
Create Date: 2026-02-15 13:57:01.055304
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "aecfd9175624"
down_revision: Union[str, None] = "34ec51d80f52"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_group
op.create_table(
"asset_group",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_group_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_group")),
)
op.create_table(
"asset_group_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_group_version")
),
)
op.create_index(
op.f("ix_asset_group_version_end_transaction_id"),
"asset_group_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_group_version_operation_type"),
"asset_group_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_group_version_pk_transaction_id",
"asset_group_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_group_version_pk_validity",
"asset_group_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_group_version_transaction_id"),
"asset_group_version",
["transaction_id"],
unique=False,
)
# group
op.drop_index(
op.f("ix_group_version_end_transaction_id"), table_name="group_version"
)
op.drop_index(op.f("ix_group_version_operation_type"), table_name="group_version")
op.drop_index(
op.f("ix_group_version_pk_transaction_id"), table_name="group_version"
)
op.drop_index(op.f("ix_group_version_pk_validity"), table_name="group_version")
op.drop_index(op.f("ix_group_version_transaction_id"), table_name="group_version")
op.drop_table("group_version")
op.drop_table("group")
def downgrade() -> None:
# group
op.create_table(
"group",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_group")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_group_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_group_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"name",
name=op.f("uq_group_name"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"group_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_group_version")
),
)
op.create_index(
op.f("ix_group_version_transaction_id"),
"group_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_group_version_pk_validity"),
"group_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_group_version_pk_transaction_id"),
"group_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_group_version_operation_type"),
"group_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_group_version_end_transaction_id"),
"group_version",
["end_transaction_id"],
unique=False,
)
# asset_group
op.drop_index(
op.f("ix_asset_group_version_transaction_id"), table_name="asset_group_version"
)
op.drop_index(
"ix_asset_group_version_pk_validity", table_name="asset_group_version"
)
op.drop_index(
"ix_asset_group_version_pk_transaction_id", table_name="asset_group_version"
)
op.drop_index(
op.f("ix_asset_group_version_operation_type"), table_name="asset_group_version"
)
op.drop_index(
op.f("ix_asset_group_version_end_transaction_id"),
table_name="asset_group_version",
)
op.drop_table("asset_group_version")
op.drop_table("asset_group")

View file

@ -0,0 +1,37 @@
"""remove AnimalType.changed
Revision ID: b8cd4a8f981f
Revises: aecfd9175624
Create Date: 2026-02-17 18:11:06.110003
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "b8cd4a8f981f"
down_revision: Union[str, None] = "aecfd9175624"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal_type
op.drop_column("animal_type", "changed")
def downgrade() -> None:
# animal_type
op.add_column(
"animal_type",
sa.Column(
"changed", postgresql.TIMESTAMP(), autoincrement=False, nullable=True
),
)

View file

@ -0,0 +1,115 @@
"""add Asset Types
Revision ID: cf3f8f46d8bc
Revises: 6c56bcd1c028
Create Date: 2026-02-10 18:42:24.560312
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "cf3f8f46d8bc"
down_revision: Union[str, None] = "6c56bcd1c028"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_type
op.create_table(
"asset_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_asset_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_asset_type_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_asset_type_name")),
)
op.create_table(
"asset_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"description", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"drupal_id", sa.String(length=50), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_type_version")
),
)
op.create_index(
op.f("ix_asset_type_version_end_transaction_id"),
"asset_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_type_version_operation_type"),
"asset_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_type_version_pk_transaction_id",
"asset_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_type_version_pk_validity",
"asset_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_type_version_transaction_id"),
"asset_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_type
op.drop_index(
op.f("ix_asset_type_version_transaction_id"), table_name="asset_type_version"
)
op.drop_index("ix_asset_type_version_pk_validity", table_name="asset_type_version")
op.drop_index(
"ix_asset_type_version_pk_transaction_id", table_name="asset_type_version"
)
op.drop_index(
op.f("ix_asset_type_version_operation_type"), table_name="asset_type_version"
)
op.drop_index(
op.f("ix_asset_type_version_end_transaction_id"),
table_name="asset_type_version",
)
op.drop_table("asset_type_version")
op.drop_table("asset_type")

View file

@ -0,0 +1,37 @@
"""add MedicalLog.vet
Revision ID: d459db991404
Revises: 9e875e5cbdc1
Create Date: 2026-02-28 22:17:57.001134
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "d459db991404"
down_revision: Union[str, None] = "9e875e5cbdc1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_medical
op.add_column("log_medical", sa.Column("vet", sa.String(length=100), nullable=True))
op.add_column(
"log_medical_version",
sa.Column("vet", sa.String(length=100), autoincrement=False, nullable=True),
)
def downgrade() -> None:
# log_medical
op.drop_column("log_medical_version", "vet")
op.drop_column("log_medical", "vet")

View file

@ -0,0 +1,333 @@
"""add generic, animal assets
Revision ID: d6e8d16d6854
Revises: 554e6168c339
Create Date: 2026-02-15 09:11:04.886362
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "d6e8d16d6854"
down_revision: Union[str, None] = "554e6168c339"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal
op.drop_table("animal")
op.drop_index(
op.f("ix_animal_version_end_transaction_id"), table_name="animal_version"
)
op.drop_index(op.f("ix_animal_version_operation_type"), table_name="animal_version")
op.drop_index(
op.f("ix_animal_version_pk_transaction_id"), table_name="animal_version"
)
op.drop_index(op.f("ix_animal_version_pk_validity"), table_name="animal_version")
op.drop_index(op.f("ix_animal_version_transaction_id"), table_name="animal_version")
op.drop_table("animal_version")
# asset
op.create_table(
"asset",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.Column("asset_type", sa.String(length=100), nullable=False),
sa.Column("asset_name", sa.String(length=100), nullable=False),
sa.Column("is_location", sa.Boolean(), nullable=False),
sa.Column("is_fixed", sa.Boolean(), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("thumbnail_url", sa.String(length=255), nullable=True),
sa.Column("image_url", sa.String(length=255), nullable=True),
sa.Column("archived", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["asset_type"],
["asset_type.drupal_id"],
name=op.f("fk_asset_asset_type_asset_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_asset_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_asset_farmos_uuid")),
)
op.create_table(
"asset_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"asset_type", sa.String(length=100), autoincrement=False, nullable=True
),
sa.Column(
"asset_name", sa.String(length=100), autoincrement=False, nullable=True
),
sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column(
"thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"image_url", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column("archived", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_version")
),
)
op.create_index(
op.f("ix_asset_version_end_transaction_id"),
"asset_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_version_operation_type"),
"asset_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_version_pk_transaction_id",
"asset_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_version_pk_validity",
"asset_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_version_transaction_id"),
"asset_version",
["transaction_id"],
unique=False,
)
# asset_animal
op.create_table(
"asset_animal",
sa.Column("animal_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("birthdate", sa.DateTime(), nullable=True),
sa.Column("sex", sa.String(length=1), nullable=True),
sa.Column("is_sterile", sa.Boolean(), nullable=True),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["animal_type_uuid"],
["animal_type.uuid"],
name=op.f("fk_asset_animal_animal_type_uuid_animal_type"),
),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_animal_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_animal")),
)
op.create_table(
"asset_animal_version",
sa.Column(
"animal_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("sex", sa.String(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_animal_version")
),
)
op.create_index(
op.f("ix_asset_animal_version_end_transaction_id"),
"asset_animal_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_animal_version_operation_type"),
"asset_animal_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_animal_version_pk_transaction_id",
"asset_animal_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_animal_version_pk_validity",
"asset_animal_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_animal_version_transaction_id"),
"asset_animal_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_animal
op.drop_index(
op.f("ix_asset_animal_version_transaction_id"),
table_name="asset_animal_version",
)
op.drop_index(
"ix_asset_animal_version_pk_validity", table_name="asset_animal_version"
)
op.drop_index(
"ix_asset_animal_version_pk_transaction_id", table_name="asset_animal_version"
)
op.drop_index(
op.f("ix_asset_animal_version_operation_type"),
table_name="asset_animal_version",
)
op.drop_index(
op.f("ix_asset_animal_version_end_transaction_id"),
table_name="asset_animal_version",
)
op.drop_table("asset_animal_version")
op.drop_table("asset_animal")
# asset
op.drop_index(op.f("ix_asset_version_transaction_id"), table_name="asset_version")
op.drop_index("ix_asset_version_pk_validity", table_name="asset_version")
op.drop_index("ix_asset_version_pk_transaction_id", table_name="asset_version")
op.drop_index(op.f("ix_asset_version_operation_type"), table_name="asset_version")
op.drop_index(
op.f("ix_asset_version_end_transaction_id"), table_name="asset_version"
)
op.drop_table("asset_version")
op.drop_table("asset")
# animal
op.create_table(
"animal",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("animal_type_uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("sex", sa.VARCHAR(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.ForeignKeyConstraint(
["animal_type_uuid"],
["animal_type.uuid"],
name=op.f("fk_animal_animal_type_uuid_animal_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_animal_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_animal_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"animal_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("animal_type_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column(
"birthdate", postgresql.TIMESTAMP(), autoincrement=False, nullable=True
),
sa.Column("sex", sa.VARCHAR(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_animal_version")
),
)
op.create_index(
op.f("ix_animal_version_transaction_id"),
"animal_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_pk_validity"),
"animal_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_pk_transaction_id"),
"animal_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_animal_version_operation_type"),
"animal_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_end_transaction_id"),
"animal_version",
["end_transaction_id"],
unique=False,
)

View file

@ -0,0 +1,116 @@
"""add Structure Types
Revision ID: d7479d7161a8
Revises: 9f2243df9566
Create Date: 2026-02-10 19:24:20.249826
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "d7479d7161a8"
down_revision: Union[str, None] = "9f2243df9566"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# structure_type
op.create_table(
"structure_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_structure_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_structure_type_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_structure_type_name")),
)
op.create_table(
"structure_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"drupal_id", sa.String(length=50), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_structure_type_version")
),
)
op.create_index(
op.f("ix_structure_type_version_end_transaction_id"),
"structure_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_type_version_operation_type"),
"structure_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_structure_type_version_pk_transaction_id",
"structure_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_structure_type_version_pk_validity",
"structure_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_type_version_transaction_id"),
"structure_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# structure_type
op.drop_index(
op.f("ix_structure_type_version_transaction_id"),
table_name="structure_type_version",
)
op.drop_index(
"ix_structure_type_version_pk_validity", table_name="structure_type_version"
)
op.drop_index(
"ix_structure_type_version_pk_transaction_id",
table_name="structure_type_version",
)
op.drop_index(
op.f("ix_structure_type_version_operation_type"),
table_name="structure_type_version",
)
op.drop_index(
op.f("ix_structure_type_version_end_transaction_id"),
table_name="structure_type_version",
)
op.drop_table("structure_type_version")
op.drop_table("structure_type")

View file

@ -0,0 +1,411 @@
"""use shared base for Land Assets
Revision ID: d882682c82f9
Revises: d6e8d16d6854
Create Date: 2026-02-15 12:00:27.036011
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "d882682c82f9"
down_revision: Union[str, None] = "d6e8d16d6854"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_parent
op.create_table(
"asset_parent",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("parent_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"],
["asset.uuid"],
name=op.f("fk_asset_parent_asset_uuid_asset"),
),
sa.ForeignKeyConstraint(
["parent_uuid"],
["asset.uuid"],
name=op.f("fk_asset_parent_parent_uuid_asset"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_parent")),
)
op.create_table(
"asset_parent_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"parent_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_parent_version")
),
)
op.create_index(
op.f("ix_asset_parent_version_end_transaction_id"),
"asset_parent_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_parent_version_operation_type"),
"asset_parent_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_parent_version_pk_transaction_id",
"asset_parent_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_parent_version_pk_validity",
"asset_parent_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_parent_version_transaction_id"),
"asset_parent_version",
["transaction_id"],
unique=False,
)
# asset_land
op.create_table(
"asset_land",
sa.Column("land_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["land_type_uuid"],
["land_type.uuid"],
name=op.f("fk_asset_land_land_type_uuid_land_type"),
),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_land_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_land")),
sa.UniqueConstraint(
"land_type_uuid", name=op.f("uq_asset_land_land_type_uuid")
),
)
op.create_table(
"asset_land_version",
sa.Column(
"land_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_land_version")
),
)
op.create_index(
op.f("ix_asset_land_version_end_transaction_id"),
"asset_land_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_land_version_operation_type"),
"asset_land_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_land_version_pk_transaction_id",
"asset_land_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_land_version_pk_validity",
"asset_land_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_land_version_transaction_id"),
"asset_land_version",
["transaction_id"],
unique=False,
)
# land_asset_parent
op.drop_index(
op.f("ix_land_asset_parent_version_end_transaction_id"),
table_name="land_asset_parent_version",
)
op.drop_index(
op.f("ix_land_asset_parent_version_operation_type"),
table_name="land_asset_parent_version",
)
op.drop_index(
op.f("ix_land_asset_parent_version_pk_transaction_id"),
table_name="land_asset_parent_version",
)
op.drop_index(
op.f("ix_land_asset_parent_version_pk_validity"),
table_name="land_asset_parent_version",
)
op.drop_index(
op.f("ix_land_asset_parent_version_transaction_id"),
table_name="land_asset_parent_version",
)
op.drop_table("land_asset_parent_version")
op.drop_table("land_asset_parent")
# land_asset
op.drop_index(
op.f("ix_land_asset_version_end_transaction_id"),
table_name="land_asset_version",
)
op.drop_index(
op.f("ix_land_asset_version_operation_type"), table_name="land_asset_version"
)
op.drop_index(
op.f("ix_land_asset_version_pk_transaction_id"), table_name="land_asset_version"
)
op.drop_index(
op.f("ix_land_asset_version_pk_validity"), table_name="land_asset_version"
)
op.drop_index(
op.f("ix_land_asset_version_transaction_id"), table_name="land_asset_version"
)
op.drop_table("land_asset_version")
op.drop_table("land_asset")
def downgrade() -> None:
# land_asset
op.create_table(
"land_asset",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("land_type_uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(
["land_type_uuid"],
["land_type.uuid"],
name=op.f("fk_land_asset_land_type_uuid_land_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_land_asset_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_land_asset_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"land_type_uuid",
name=op.f("uq_land_asset_land_type_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"name",
name=op.f("uq_land_asset_name"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"land_asset_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("land_type_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_land_asset_version")
),
)
op.create_index(
op.f("ix_land_asset_version_transaction_id"),
"land_asset_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_pk_validity"),
"land_asset_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_pk_transaction_id"),
"land_asset_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_operation_type"),
"land_asset_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_end_transaction_id"),
"land_asset_version",
["end_transaction_id"],
unique=False,
)
# land_asset_parent
op.create_table(
"land_asset_parent",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("land_asset_uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("parent_asset_uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(
["land_asset_uuid"],
["land_asset.uuid"],
name=op.f("fk_land_asset_parent_land_asset_uuid_land_asset"),
),
sa.ForeignKeyConstraint(
["parent_asset_uuid"],
["land_asset.uuid"],
name=op.f("fk_land_asset_parent_parent_asset_uuid_land_asset"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset_parent")),
)
op.create_table(
"land_asset_parent_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("land_asset_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("parent_asset_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_land_asset_parent_version")
),
)
op.create_index(
op.f("ix_land_asset_parent_version_transaction_id"),
"land_asset_parent_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_parent_version_pk_validity"),
"land_asset_parent_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_parent_version_pk_transaction_id"),
"land_asset_parent_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_land_asset_parent_version_operation_type"),
"land_asset_parent_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_parent_version_end_transaction_id"),
"land_asset_parent_version",
["end_transaction_id"],
unique=False,
)
# asset_land
op.drop_table("asset_land")
op.drop_index(
op.f("ix_asset_land_version_transaction_id"), table_name="asset_land_version"
)
op.drop_index("ix_asset_land_version_pk_validity", table_name="asset_land_version")
op.drop_index(
"ix_asset_land_version_pk_transaction_id", table_name="asset_land_version"
)
op.drop_index(
op.f("ix_asset_land_version_operation_type"), table_name="asset_land_version"
)
op.drop_index(
op.f("ix_asset_land_version_end_transaction_id"),
table_name="asset_land_version",
)
op.drop_table("asset_land_version")
# asset_parent
op.drop_index(
op.f("ix_asset_parent_version_transaction_id"),
table_name="asset_parent_version",
)
op.drop_index(
"ix_asset_parent_version_pk_validity", table_name="asset_parent_version"
)
op.drop_index(
"ix_asset_parent_version_pk_transaction_id", table_name="asset_parent_version"
)
op.drop_index(
op.f("ix_asset_parent_version_operation_type"),
table_name="asset_parent_version",
)
op.drop_index(
op.f("ix_asset_parent_version_end_transaction_id"),
table_name="asset_parent_version",
)
op.drop_table("asset_parent_version")
op.drop_table("asset_parent")

View file

@ -0,0 +1,206 @@
"""add generic log base
Revision ID: dd6351e69233
Revises: b8cd4a8f981f
Create Date: 2026-02-18 12:09:05.200134
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "dd6351e69233"
down_revision: Union[str, None] = "b8cd4a8f981f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log
op.create_table(
"log",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_type", sa.String(length=100), nullable=False),
sa.Column("message", sa.String(length=255), nullable=False),
sa.Column("timestamp", sa.DateTime(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["log_type"], ["log_type.drupal_id"], name=op.f("fk_log_log_type_log_type")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_log_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_farmos_uuid")),
)
op.create_table(
"log_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_type", sa.String(length=100), autoincrement=False, nullable=True
),
sa.Column("message", sa.String(length=255), autoincrement=False, nullable=True),
sa.Column("timestamp", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("status", sa.String(length=20), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint("uuid", "transaction_id", name=op.f("pk_log_version")),
)
op.create_index(
op.f("ix_log_version_end_transaction_id"),
"log_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_version_operation_type"),
"log_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_version_pk_transaction_id",
"log_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_version_pk_validity",
"log_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_version_transaction_id"),
"log_version",
["transaction_id"],
unique=False,
)
# log_activity
op.drop_column("log_activity_version", "status")
op.drop_column("log_activity_version", "farmos_uuid")
op.drop_column("log_activity_version", "timestamp")
op.drop_column("log_activity_version", "message")
op.drop_column("log_activity_version", "drupal_id")
op.drop_column("log_activity_version", "notes")
op.drop_constraint(
op.f("uq_log_activity_drupal_id"), "log_activity", type_="unique"
)
op.drop_constraint(
op.f("uq_log_activity_farmos_uuid"), "log_activity", type_="unique"
)
op.create_foreign_key(
op.f("fk_log_activity_uuid_log"), "log_activity", "log", ["uuid"], ["uuid"]
)
op.drop_column("log_activity", "status")
op.drop_column("log_activity", "farmos_uuid")
op.drop_column("log_activity", "timestamp")
op.drop_column("log_activity", "message")
op.drop_column("log_activity", "drupal_id")
op.drop_column("log_activity", "notes")
def downgrade() -> None:
# log_activity
op.add_column(
"log_activity",
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
)
op.add_column(
"log_activity",
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
)
op.add_column(
"log_activity",
sa.Column(
"message", sa.VARCHAR(length=255), autoincrement=False, nullable=False
),
)
op.add_column(
"log_activity",
sa.Column(
"timestamp", postgresql.TIMESTAMP(), autoincrement=False, nullable=False
),
)
op.add_column(
"log_activity",
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
)
op.add_column(
"log_activity",
sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=False),
)
op.drop_constraint(
op.f("fk_log_activity_uuid_log"), "log_activity", type_="foreignkey"
)
op.create_unique_constraint(
op.f("uq_log_activity_farmos_uuid"),
"log_activity",
["farmos_uuid"],
postgresql_nulls_not_distinct=False,
)
op.create_unique_constraint(
op.f("uq_log_activity_drupal_id"),
"log_activity",
["drupal_id"],
postgresql_nulls_not_distinct=False,
)
op.add_column(
"log_activity_version",
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
)
op.add_column(
"log_activity_version",
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
)
op.add_column(
"log_activity_version",
sa.Column(
"message", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
)
op.add_column(
"log_activity_version",
sa.Column(
"timestamp", postgresql.TIMESTAMP(), autoincrement=False, nullable=True
),
)
op.add_column(
"log_activity_version",
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
)
op.add_column(
"log_activity_version",
sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=True),
)
# log
op.drop_index(op.f("ix_log_version_transaction_id"), table_name="log_version")
op.drop_index("ix_log_version_pk_validity", table_name="log_version")
op.drop_index("ix_log_version_pk_transaction_id", table_name="log_version")
op.drop_index(op.f("ix_log_version_operation_type"), table_name="log_version")
op.drop_index(op.f("ix_log_version_end_transaction_id"), table_name="log_version")
op.drop_table("log_version")
op.drop_table("log")

View file

@ -0,0 +1,114 @@
"""add Log Types
Revision ID: e0d9f72575d6
Revises: d7479d7161a8
Create Date: 2026-02-10 19:35:06.631814
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "e0d9f72575d6"
down_revision: Union[str, None] = "d7479d7161a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_type
op.create_table(
"log_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_log_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_type_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_log_type_name")),
)
op.create_table(
"log_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"description", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"drupal_id", sa.String(length=50), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_type_version")
),
)
op.create_index(
op.f("ix_log_type_version_end_transaction_id"),
"log_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_type_version_operation_type"),
"log_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_type_version_pk_transaction_id",
"log_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_type_version_pk_validity",
"log_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_type_version_transaction_id"),
"log_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_type
op.drop_index(
op.f("ix_log_type_version_transaction_id"), table_name="log_type_version"
)
op.drop_index("ix_log_type_version_pk_validity", table_name="log_type_version")
op.drop_index(
"ix_log_type_version_pk_transaction_id", table_name="log_type_version"
)
op.drop_index(
op.f("ix_log_type_version_operation_type"), table_name="log_type_version"
)
op.drop_index(
op.f("ix_log_type_version_end_transaction_id"), table_name="log_type_version"
)
op.drop_table("log_type_version")
op.drop_table("log_type")

View file

@ -0,0 +1,132 @@
"""add Land Assets
Revision ID: e416b96467fc
Revises: e0d9f72575d6
Create Date: 2026-02-13 09:39:31.327442
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "e416b96467fc"
down_revision: Union[str, None] = "e0d9f72575d6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# land_asset
op.create_table(
"land_asset",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("land_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("is_location", sa.Boolean(), nullable=False),
sa.Column("is_fixed", sa.Boolean(), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["land_type_uuid"],
["land_type.uuid"],
name=op.f("fk_land_asset_land_type_uuid_land_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_land_asset_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_land_asset_farmos_uuid")),
sa.UniqueConstraint(
"land_type_uuid", name=op.f("uq_land_asset_land_type_uuid")
),
sa.UniqueConstraint("name", name=op.f("uq_land_asset_name")),
)
op.create_table(
"land_asset_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"land_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_land_asset_version")
),
)
op.create_index(
op.f("ix_land_asset_version_end_transaction_id"),
"land_asset_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_operation_type"),
"land_asset_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_land_asset_version_pk_transaction_id",
"land_asset_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_land_asset_version_pk_validity",
"land_asset_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_transaction_id"),
"land_asset_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# land_asset
op.drop_index(
op.f("ix_land_asset_version_transaction_id"), table_name="land_asset_version"
)
op.drop_index("ix_land_asset_version_pk_validity", table_name="land_asset_version")
op.drop_index(
"ix_land_asset_version_pk_transaction_id", table_name="land_asset_version"
)
op.drop_index(
op.f("ix_land_asset_version_operation_type"), table_name="land_asset_version"
)
op.drop_index(
op.f("ix_land_asset_version_end_transaction_id"),
table_name="land_asset_version",
)
op.drop_table("land_asset_version")
op.drop_table("land_asset")

View file

@ -0,0 +1,102 @@
"""add Units
Revision ID: ea88e72a5fa5
Revises: 82a03f4ef1a4
Create Date: 2026-02-18 20:01:40.720138
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "ea88e72a5fa5"
down_revision: Union[str, None] = "82a03f4ef1a4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# unit
op.create_table(
"unit",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_unit")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_unit_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_unit_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_unit_name")),
)
op.create_table(
"unit_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"description", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint("uuid", "transaction_id", name=op.f("pk_unit_version")),
)
op.create_index(
op.f("ix_unit_version_end_transaction_id"),
"unit_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_unit_version_operation_type"),
"unit_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_unit_version_pk_transaction_id",
"unit_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_unit_version_pk_validity",
"unit_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_unit_version_transaction_id"),
"unit_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# unit
op.drop_index(op.f("ix_unit_version_transaction_id"), table_name="unit_version")
op.drop_index("ix_unit_version_pk_validity", table_name="unit_version")
op.drop_index("ix_unit_version_pk_transaction_id", table_name="unit_version")
op.drop_index(op.f("ix_unit_version_operation_type"), table_name="unit_version")
op.drop_index(op.f("ix_unit_version_end_transaction_id"), table_name="unit_version")
op.drop_table("unit_version")
op.drop_table("unit")

View file

@ -0,0 +1,39 @@
"""add Log.is_group_assignment
Revision ID: f3c7e273bfa3
Revises: 47d0ebd84554
Create Date: 2026-02-28 20:04:40.700474
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "f3c7e273bfa3"
down_revision: Union[str, None] = "47d0ebd84554"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log
op.add_column("log", sa.Column("is_group_assignment", sa.Boolean(), nullable=True))
op.add_column(
"log_version",
sa.Column(
"is_group_assignment", sa.Boolean(), autoincrement=False, nullable=True
),
)
def downgrade() -> None:
# log
op.drop_column("log_version", "is_group_assignment")
op.drop_column("log", "is_group_assignment")

View file

@ -26,4 +26,20 @@ WuttaFarm data models
# bring in all of wutta
from wuttjamaican.db.model import *
# TODO: import other/custom models here...
# wutta model extensions
from .users import WuttaFarmUser
# wuttafarm proper models
from .unit import Unit, Measure
from .quantities import QuantityType, Quantity, StandardQuantity
from .asset import AssetType, Asset, AssetParent
from .asset_land import LandType, LandAsset
from .asset_structure import StructureType, StructureAsset
from .asset_animal import AnimalType, AnimalAsset
from .asset_group import GroupAsset
from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType
from .log import LogType, Log, LogAsset, LogGroup, LogLocation, LogQuantity, LogOwner
from .log_activity import ActivityLog
from .log_harvest import HarvestLog
from .log_medical import MedicalLog
from .log_observation import ObservationLog

View file

@ -0,0 +1,303 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Asset Types
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
class AssetType(model.Base):
"""
Represents an "asset type" from farmOS
"""
__tablename__ = "asset_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Asset Type",
"model_title_plural": "Asset Types",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the asset type.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Description for the asset type.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the asset type within farmOS.
""",
)
drupal_id = sa.Column(
sa.String(length=50),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the asset type.
""",
)
def __str__(self):
return self.name or ""
class Asset(model.Base):
"""
Represents an asset (of any kind) from farmOS.
"""
__tablename__ = "asset"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Asset",
"model_title_plural": "All Assets",
}
uuid = model.uuid_column()
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the asset within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the asset.
""",
)
asset_type = sa.Column(
sa.String(length=100), sa.ForeignKey("asset_type.drupal_id"), nullable=False
)
asset_name = sa.Column(
sa.String(length=100),
nullable=False,
doc="""
Name of the asset.
""",
)
is_location = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset should be considered a location.
""",
)
is_fixed = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset's location is fixed.
""",
)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Notes for the asset.
""",
)
thumbnail_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional thumbnail URL for the asset.
""",
)
image_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional image URL for the asset.
""",
)
archived = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset is archived.
""",
)
_parents = orm.relationship(
"AssetParent",
foreign_keys="AssetParent.asset_uuid",
back_populates="asset",
cascade="all, delete-orphan",
cascade_backrefs=False,
)
parents = association_proxy(
"_parents",
"parent",
creator=lambda parent: AssetParent(parent=parent),
)
_owners = orm.relationship(
"AssetOwner",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="asset",
)
owners = association_proxy(
"_owners",
"user",
creator=lambda user: AssetOwner(user=user),
)
def __str__(self):
return self.asset_name or ""
class AssetMixin:
uuid = model.uuid_fk_column("asset.uuid", nullable=False, primary_key=True)
@declared_attr
def asset(cls):
return orm.relationship(
Asset,
single_parent=True,
cascade="all, delete-orphan",
cascade_backrefs=False,
)
def __str__(self):
return self.asset_name or ""
def add_asset_proxies(subclass):
Asset.make_proxy(subclass, "asset", "farmos_uuid")
Asset.make_proxy(subclass, "asset", "drupal_id")
Asset.make_proxy(subclass, "asset", "asset_type")
Asset.make_proxy(subclass, "asset", "asset_name")
Asset.make_proxy(subclass, "asset", "is_location")
Asset.make_proxy(subclass, "asset", "is_fixed")
Asset.make_proxy(subclass, "asset", "notes")
Asset.make_proxy(subclass, "asset", "thumbnail_url")
Asset.make_proxy(subclass, "asset", "image_url")
Asset.make_proxy(subclass, "asset", "archived")
Asset.make_proxy(subclass, "asset", "parents")
Asset.make_proxy(subclass, "asset", "owners")
class EggMixin:
produces_eggs = sa.Column(
sa.Boolean(),
nullable=True,
doc="""
Whether the group asset produces eggs (i.e. it should be
available in the egg harvest form).
""",
)
class AssetParent(model.Base):
"""
Represents an "asset's parent relationship" from farmOS.
"""
__tablename__ = "asset_parent"
__versioned__ = {}
uuid = model.uuid_column()
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
Asset,
foreign_keys=asset_uuid,
)
parent_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
parent = orm.relationship(
Asset,
foreign_keys=parent_uuid,
)
class AssetOwner(model.Base):
"""
Represents a "asset's owner relationship" from farmOS.
"""
__tablename__ = "asset_owner"
__versioned__ = {}
uuid = model.uuid_column()
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
Asset,
foreign_keys=asset_uuid,
back_populates="_owners",
)
user_uuid = model.uuid_fk_column("user.uuid", nullable=False)
user = orm.relationship(
model.User,
foreign_keys=user_uuid,
)

View file

@ -0,0 +1,141 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Animal Types
"""
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies, EggMixin
class AnimalType(model.Base):
"""
Represents an "animal type" (taxonomy term) from farmOS
"""
__tablename__ = "animal_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Animal Type",
"model_title_plural": "Animal Types",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
doc="""
Name of the animal type.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional description for the animal type.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the animal type within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the animal type.
""",
)
animal_assets = orm.relationship(
"AnimalAsset",
doc="""
List of animal assets of this type.
""",
back_populates="animal_type",
)
def __str__(self):
return self.name or ""
class AnimalAsset(AssetMixin, EggMixin, model.Base):
"""
Represents an animal asset from farmOS
"""
__tablename__ = "asset_animal"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Animal Asset",
"model_title_plural": "Animal Assets",
"farmos_asset_type": "animal",
}
animal_type_uuid = model.uuid_fk_column("animal_type.uuid", nullable=False)
animal_type = orm.relationship(
"AnimalType",
doc="""
Reference to the animal type.
""",
back_populates="animal_assets",
)
birthdate = sa.Column(
sa.DateTime(),
nullable=True,
doc="""
Birth date (and time) for the animal, if known.
""",
)
sex = sa.Column(
sa.String(length=1),
nullable=True,
doc="""
Sex of the animal.
""",
)
is_sterile = sa.Column(
sa.Boolean(),
nullable=True,
doc="""
Whether the animal is sterile (e.g. castrated).
""",
)
add_asset_proxies(AnimalAsset)

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Groups
"""
from wuttjamaican.db import model
from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies, EggMixin
class GroupAsset(AssetMixin, EggMixin, model.Base):
"""
Represents a group asset from farmOS
"""
__tablename__ = "asset_group"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Group Asset",
"model_title_plural": "Group Assets",
"farmos_asset_type": "group",
}
add_asset_proxies(GroupAsset)

View file

@ -0,0 +1,98 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Land Types
"""
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
class LandType(model.Base):
"""
Represents a "land type" from farmOS
"""
__tablename__ = "land_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Land Type",
"model_title_plural": "Land Types",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the land type.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the land type within farmOS.
""",
)
drupal_id = sa.Column(
sa.String(length=50),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the land type.
""",
)
land_assets = orm.relationship("LandAsset", back_populates="land_type")
def __str__(self):
return self.name or ""
class LandAsset(AssetMixin, model.Base):
"""
Represents a "land asset" from farmOS
"""
__tablename__ = "asset_land"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Land Asset",
"model_title_plural": "Land Assets",
"farmos_asset_type": "land",
}
land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False)
land_type = orm.relationship(LandType, back_populates="land_assets")
add_asset_proxies(LandAsset)

View file

@ -0,0 +1,148 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Plant Assets
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
class PlantType(model.Base):
"""
Represents a "plant type" (taxonomy term) from farmOS
"""
__tablename__ = "plant_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Plant Type",
"model_title_plural": "Plant Types",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the plant type.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional description for the plant type.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the plant type within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the plant type.
""",
)
_plant_assets = orm.relationship(
"PlantAssetPlantType",
cascade_backrefs=False,
back_populates="plant_type",
)
def __str__(self):
return self.name or ""
class PlantAsset(AssetMixin, model.Base):
"""
Represents a plant asset from farmOS
"""
__tablename__ = "asset_plant"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Plant Asset",
"model_title_plural": "Plant Assets",
"farmos_asset_type": "plant",
}
_plant_types = orm.relationship(
"PlantAssetPlantType",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="plant_asset",
)
plant_types = association_proxy(
"_plant_types",
"plant_type",
creator=lambda pt: PlantAssetPlantType(plant_type=pt),
)
add_asset_proxies(PlantAsset)
class PlantAssetPlantType(model.Base):
"""
Associates one or more plant types with a plant asset.
"""
__tablename__ = "asset_plant_plant_type"
__versioned__ = {}
uuid = model.uuid_column()
plant_asset_uuid = model.uuid_fk_column("asset_plant.uuid", nullable=False)
plant_asset = orm.relationship(
PlantAsset,
foreign_keys=plant_asset_uuid,
back_populates="_plant_types",
)
plant_type_uuid = model.uuid_fk_column("plant_type.uuid", nullable=False)
plant_type = orm.relationship(
PlantType,
doc="""
Reference to the plant type.
""",
back_populates="_plant_assets",
)

View file

@ -0,0 +1,101 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Structure Types
"""
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
class StructureType(model.Base):
"""
Represents a "structure type" from farmOS
"""
__tablename__ = "structure_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Structure Type",
"model_title_plural": "Structure Types",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the structure type.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the structure type within farmOS.
""",
)
drupal_id = sa.Column(
sa.String(length=50),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the structure type.
""",
)
def __str__(self):
return self.name or ""
class StructureAsset(AssetMixin, model.Base):
"""
Represents a structure from farmOS
"""
__tablename__ = "asset_structure"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Structure Asset",
"model_title_plural": "Structure Assets",
"farmos_asset_type": "structure",
}
structure_type_uuid = model.uuid_fk_column("structure_type.uuid", nullable=False)
structure_type = orm.relationship(
"StructureType",
doc="""
Reference to the type of structure.
""",
)
add_asset_proxies(StructureAsset)

View file

@ -0,0 +1,404 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Logs
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
class LogType(model.Base):
"""
Represents a "log type" from farmOS
"""
__tablename__ = "log_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Log Type",
"model_title_plural": "Log Types",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the log type.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional description for the log type.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the log type within farmOS.
""",
)
drupal_id = sa.Column(
sa.String(length=50),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the log type.
""",
)
def __str__(self):
return self.name or ""
class Log(model.Base):
"""
Represents a base log record from farmOS
"""
__tablename__ = "log"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Log",
"model_title_plural": "All Logs",
}
uuid = model.uuid_column()
log_type = sa.Column(
sa.String(length=100),
sa.ForeignKey("log_type.drupal_id"),
nullable=False,
)
message = sa.Column(
sa.String(length=255),
nullable=False,
doc="""
Message text for the log.
""",
)
timestamp = sa.Column(
sa.DateTime(),
nullable=False,
doc="""
Date and time when the log event occurred / will occur.
""",
)
is_movement = sa.Column(
sa.Boolean(),
nullable=True,
doc="""
Whether the log represents a movement to new location.
""",
)
is_group_assignment = sa.Column(
sa.Boolean(),
nullable=True,
doc="""
Whether the log represents a group assignment.
""",
)
status = sa.Column(
sa.String(length=20),
nullable=False,
doc="""
Current status of the log event.
""",
)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Arbitrary notes for the log event.
""",
)
quick = sa.Column(
sa.String(length=20),
nullable=True,
doc="""
Identifier of quick form used to create the log, if
applicable.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the log within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the log.
""",
)
_assets = orm.relationship(
"LogAsset",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
assets = association_proxy(
"_assets",
"asset",
creator=lambda asset: LogAsset(asset=asset),
)
_groups = orm.relationship(
"LogGroup",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
groups = association_proxy(
"_groups",
"asset",
creator=lambda asset: LogGroup(asset=asset),
)
_locations = orm.relationship(
"LogLocation",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
locations = association_proxy(
"_locations",
"asset",
creator=lambda asset: LogLocation(asset=asset),
)
_quantities = orm.relationship(
"LogQuantity",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
quantities = association_proxy(
"_quantities",
"quantity",
creator=lambda quantity: LogQuantity(quantity=quantity),
)
_owners = orm.relationship(
"LogOwner",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
owners = association_proxy(
"_owners",
"user",
creator=lambda user: LogOwner(user=user),
)
def __str__(self):
return self.message or ""
class LogMixin:
uuid = model.uuid_fk_column("log.uuid", nullable=False, primary_key=True)
@declared_attr
def log(cls):
return orm.relationship(
Log,
single_parent=True,
cascade="all, delete-orphan",
cascade_backrefs=False,
)
def __str__(self):
return self.message or ""
def add_log_proxies(subclass):
Log.make_proxy(subclass, "log", "farmos_uuid")
Log.make_proxy(subclass, "log", "drupal_id")
Log.make_proxy(subclass, "log", "log_type")
Log.make_proxy(subclass, "log", "message")
Log.make_proxy(subclass, "log", "timestamp")
Log.make_proxy(subclass, "log", "is_movement")
Log.make_proxy(subclass, "log", "is_group_assignment")
Log.make_proxy(subclass, "log", "status")
Log.make_proxy(subclass, "log", "notes")
Log.make_proxy(subclass, "log", "quick")
Log.make_proxy(subclass, "log", "assets")
Log.make_proxy(subclass, "log", "groups")
Log.make_proxy(subclass, "log", "locations")
Log.make_proxy(subclass, "log", "quantities")
Log.make_proxy(subclass, "log", "owners")
class LogAsset(model.Base):
"""
Represents a "log's asset relationship" from farmOS.
"""
__tablename__ = "log_asset"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_assets",
)
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
"Asset",
foreign_keys=asset_uuid,
)
class LogGroup(model.Base):
"""
Represents a "log's group relationship" from farmOS.
"""
__tablename__ = "log_group"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_groups",
)
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
"Asset",
foreign_keys=asset_uuid,
)
class LogLocation(model.Base):
"""
Represents a "log's location relationship" from farmOS.
"""
__tablename__ = "log_location"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_locations",
)
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
"Asset",
foreign_keys=asset_uuid,
)
class LogQuantity(model.Base):
"""
Represents a "log's quantity relationship" from farmOS.
"""
__tablename__ = "log_quantity"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_quantities",
)
quantity_uuid = model.uuid_fk_column("quantity.uuid", nullable=False)
quantity = orm.relationship(
"Quantity",
foreign_keys=quantity_uuid,
back_populates="_log",
)
class LogOwner(model.Base):
"""
Represents a "log's owner relationship" from farmOS.
"""
__tablename__ = "log_owner"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_owners",
)
user_uuid = model.uuid_fk_column("user.uuid", nullable=False)
user = orm.relationship(
model.User,
foreign_keys=user_uuid,
)

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Activity Logs
"""
from wuttjamaican.db import model
from wuttafarm.db.model.log import LogMixin, add_log_proxies
class ActivityLog(LogMixin, model.Base):
"""
Represents an Activity Log from farmOS
"""
__tablename__ = "log_activity"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Activity Log",
"model_title_plural": "Activity Logs",
"farmos_log_type": "activity",
}
add_log_proxies(ActivityLog)

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Harvest Logs
"""
from wuttjamaican.db import model
from wuttafarm.db.model.log import LogMixin, add_log_proxies
class HarvestLog(LogMixin, model.Base):
"""
Represents a Harvest Log from farmOS
"""
__tablename__ = "log_harvest"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Harvest Log",
"model_title_plural": "Harvest Logs",
"farmos_log_type": "harvest",
}
add_log_proxies(HarvestLog)

View file

@ -0,0 +1,55 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Medical Logs
"""
import sqlalchemy as sa
from wuttjamaican.db import model
from wuttafarm.db.model.log import LogMixin, add_log_proxies
class MedicalLog(LogMixin, model.Base):
"""
Represents a Medical Log from farmOS
"""
__tablename__ = "log_medical"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Medical Log",
"model_title_plural": "Medical Logs",
"farmos_log_type": "medical",
}
vet = sa.Column(
sa.String(length=100),
nullable=True,
doc="""
Name of the veterinarian, if applicable.
""",
)
add_log_proxies(MedicalLog)

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Observation Logs
"""
from wuttjamaican.db import model
from wuttafarm.db.model.log import LogMixin, add_log_proxies
class ObservationLog(LogMixin, model.Base):
"""
Represents a Observation Log from farmOS
"""
__tablename__ = "log_observation"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Observation Log",
"model_title_plural": "Observation Logs",
"farmos_log_type": "observation",
}
add_log_proxies(ObservationLog)

View file

@ -0,0 +1,242 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Quantities
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
class QuantityType(model.Base):
"""
Represents an "quantity type" from farmOS
"""
__tablename__ = "quantity_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Quantity Type",
"model_title_plural": "Quantity Types",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the quantity type.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Description for the quantity type.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the quantity type within farmOS.
""",
)
drupal_id = sa.Column(
sa.String(length=50),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the quantity type.
""",
)
def __str__(self):
return self.name or ""
class Quantity(model.Base):
"""
Represents a base quantity record from farmOS
"""
__tablename__ = "quantity"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Quantity",
"model_title_plural": "All Quantities",
}
uuid = model.uuid_column()
quantity_type_id = sa.Column(
sa.String(length=50),
sa.ForeignKey("quantity_type.drupal_id"),
nullable=False,
)
quantity_type = orm.relationship(QuantityType)
measure_id = sa.Column(
sa.String(length=20),
sa.ForeignKey("measure.drupal_id"),
nullable=False,
doc="""
Measure for the quantity.
""",
)
measure = orm.relationship("Measure")
value_numerator = sa.Column(
sa.Integer(),
nullable=False,
doc="""
Numerator for the quantity value.
""",
)
value_denominator = sa.Column(
sa.Integer(),
nullable=False,
doc="""
Denominator for the quantity value.
""",
)
units_uuid = model.uuid_fk_column("unit.uuid", nullable=False)
units = orm.relationship("Unit")
label = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional label for the quantity.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the quantity within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the quantity.
""",
)
_log = orm.relationship(
"LogQuantity",
uselist=False,
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="quantity",
)
def make_log_quantity(log):
from wuttafarm.db.model import LogQuantity
return LogQuantity(log=log)
log = association_proxy(
"_log",
"log",
creator=make_log_quantity,
)
def render_as_text(self, config=None):
measure = str(self.measure or self.measure_id or "")
value = self.value_numerator / self.value_denominator
if config:
app = config.get_app()
value = app.render_quantity(value)
units = str(self.units or "")
return f"( {measure} ) {value} {units}"
def __str__(self):
return self.render_as_text()
class QuantityMixin:
uuid = model.uuid_fk_column("quantity.uuid", nullable=False, primary_key=True)
@declared_attr
def quantity(cls):
return orm.relationship(Quantity)
def render_as_text(self, config=None):
return self.quantity.render_as_text(config)
def __str__(self):
return self.render_as_text()
def add_quantity_proxies(subclass):
Quantity.make_proxy(subclass, "quantity", "farmos_uuid")
Quantity.make_proxy(subclass, "quantity", "drupal_id")
Quantity.make_proxy(subclass, "quantity", "quantity_type")
Quantity.make_proxy(subclass, "quantity", "quantity_type_id")
Quantity.make_proxy(subclass, "quantity", "measure")
Quantity.make_proxy(subclass, "quantity", "measure_id")
Quantity.make_proxy(subclass, "quantity", "value_numerator")
Quantity.make_proxy(subclass, "quantity", "value_denominator")
Quantity.make_proxy(subclass, "quantity", "value_decimal")
Quantity.make_proxy(subclass, "quantity", "units_uuid")
Quantity.make_proxy(subclass, "quantity", "units")
Quantity.make_proxy(subclass, "quantity", "label")
Quantity.make_proxy(subclass, "quantity", "log")
class StandardQuantity(QuantityMixin, model.Base):
"""
Represents a Standard Quantity from farmOS
"""
__tablename__ = "quantity_standard"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Standard Quantity",
"model_title_plural": "Standard Quantities",
"farmos_quantity_type": "standard",
}
add_quantity_proxies(StandardQuantity)

View file

@ -0,0 +1,117 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Units
"""
import sqlalchemy as sa
from wuttjamaican.db import model
class Measure(model.Base):
"""
Represents a "measure" option (for quantities) from farmOS
"""
__tablename__ = "measure"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Measure",
"model_title_plural": "Measures",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the measure.
""",
)
drupal_id = sa.Column(
sa.String(length=20),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the measure.
""",
)
def __str__(self):
return self.name or ""
class Unit(model.Base):
"""
Represents an "unit" (taxonomy term) from farmOS
"""
__tablename__ = "unit"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Unit",
"model_title_plural": "Units",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the unit.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional description for the unit.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the unit within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the unit.
""",
)
def __str__(self):
return self.name or ""

View file

@ -0,0 +1,80 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for Users (extension)
"""
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
class WuttaFarmUser(model.Base):
"""
WuttaFarm extension for the User model.
"""
__tablename__ = "wuttafarm_user"
__versioned__ = {}
uuid = model.uuid_column(sa.ForeignKey("user.uuid"), default=None)
user = orm.relationship(
model.User,
doc="""
Reference to the User which this record extends.
""",
backref=orm.backref(
"_wuttafarm",
uselist=False,
cascade="all, delete-orphan",
cascade_backrefs=False,
doc="""
Reference to the WuttaFarm-specific extension record for
the user.
""",
),
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
doc="""
UUID for the user within farmOS
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
doc="""
Drupal internal ID for the user.
""",
)
def __str__(self):
return str(self.user or "")
WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "farmos_uuid")
WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "drupal_id")

38
src/wuttafarm/emails.py Normal file
View file

@ -0,0 +1,38 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Email sending config for WuttaFarm
"""
from wuttasync.emails import ImportExportWarning
class export_to_farmos_from_wuttafarm_warning(ImportExportWarning):
"""
Diff warning for WuttaFarm farmOS export.
"""
class import_to_wuttafarm_from_farmos_warning(ImportExportWarning):
"""
Diff warning for farmOS WuttaFarm import.
"""

58
src/wuttafarm/enum.py Normal file
View file

@ -0,0 +1,58 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttaFarm enum values
"""
from collections import OrderedDict
from wuttjamaican.enum import *
FARMOS_INTEGRATION_MODE_WRAPPER = "wrapper"
FARMOS_INTEGRATION_MODE_MIRROR = "mirror"
FARMOS_INTEGRATION_MODE_NONE = "none"
FARMOS_INTEGRATION_MODE = OrderedDict(
[
(FARMOS_INTEGRATION_MODE_WRAPPER, "wrapper (API only)"),
(FARMOS_INTEGRATION_MODE_MIRROR, "mirror (2-way sync)"),
(FARMOS_INTEGRATION_MODE_NONE, "none (standalone)"),
]
)
ANIMAL_SEX = OrderedDict(
[
("M", "Male"),
("F", "Female"),
]
)
LOG_STATUS = OrderedDict(
[
("pending", "Pending"),
("done", "Done"),
("abandoned", "Abandoned"),
]
)

View file

@ -42,6 +42,35 @@ class FarmOSHandler(GenericHandler):
hostname = self.get_farmos_url()
return farmOS(hostname, **kwargs)
def get_farmos_version(self, client=None, *args, **kwargs):
"""
Returns the farmOS version in use.
"""
if not client:
client = self.get_farmos_client(*args, **kwargs)
info = client.info()
return info["meta"]["farm"]["version"]
def is_farmos_3x(self, client=None, *args, **kwargs):
"""
Check if the farmOS version is 3.x.
"""
if not client:
client = self.get_farmos_client(*args, **kwargs)
version = self.get_farmos_version(client)
return version[0] == "3"
def is_farmos_4x(self, client=None, *args, **kwargs):
"""
Check if the farmOS version is 4.x.
"""
if not client:
client = self.get_farmos_client(*args, **kwargs)
version = self.get_farmos_version(client)
return version[0] == "4"
def get_farmos_url(self, path=None, require=True):
"""
Returns the base URL for farmOS, or one with ``path`` appended.
@ -65,3 +94,9 @@ class FarmOSHandler(GenericHandler):
return f"{base}/{path}"
return base
def get_oauth2_client_id(self):
return self.config.get("farmos.oauth2.client_id", default="farm")
def get_oauth2_scope(self):
return self.config.get("farmos.oauth2.scope", default="farm_manager")

View file

@ -0,0 +1,26 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Importing data *into* farmOS
"""
from . import model

View file

@ -0,0 +1,758 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Importer models targeting farmOS
"""
import datetime
from uuid import UUID
import requests
from wuttasync.importing import Importer
class ToFarmOS(Importer):
"""
Base class for data importer targeting the farmOS API.
"""
key = "uuid"
caches_target = True
def format_datetime(self, dt):
"""
Convert a WuttaFarm datetime object to the format required for
pushing to the farmOS API.
"""
if dt is None:
return None
dt = self.app.localtime(dt)
return dt.timestamp()
def normalize_datetime(self, dt):
"""
Convert a farmOS datetime value to naive UTC used by
WuttaFarm.
:param dt: Date/time string value "as-is" from the farmOS API.
:returns: Equivalent naive UTC ``datetime``
"""
if dt is None:
return None
dt = datetime.datetime.fromisoformat(dt)
return self.app.make_utc(dt)
class ToFarmOSTaxonomy(ToFarmOS):
farmos_taxonomy_type = None
supported_fields = [
"uuid",
"name",
]
def get_target_objects(self, **kwargs):
result = self.farmos_client.resource.get(
"taxonomy_term", self.farmos_taxonomy_type
)
return result["data"]
def get_target_object(self, key):
# fetch from cache, if applicable
if self.caches_target:
return super().get_target_object(key)
# okay now must fetch via API
if self.get_keys() != ["uuid"]:
raise ValueError("must use uuid key for this to work")
uuid = key[0]
try:
result = self.farmos_client.resource.get_id(
"taxonomy_term", self.farmos_taxonomy_type, str(uuid)
)
except requests.HTTPError as exc:
if exc.response.status_code == 404:
return None
raise
return result["data"]
def normalize_target_object(self, obj):
return {
"uuid": UUID(obj["id"]),
"name": obj["attributes"]["name"],
}
def get_term_payload(self, source_data):
return {
"attributes": {
"name": source_data["name"],
}
}
def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"):
return None
if self.dry_run:
return source_data
payload = self.get_term_payload(source_data)
result = self.farmos_client.resource.send(
"taxonomy_term", self.farmos_taxonomy_type, payload
)
normal = self.normalize_target_object(result["data"])
normal["_new_object"] = result["data"]
return normal
def update_target_object(self, asset, source_data, target_data=None):
if self.dry_run:
return asset
payload = self.get_term_payload(source_data)
payload["id"] = str(source_data["uuid"])
result = self.farmos_client.resource.send(
"taxonomy_term", self.farmos_taxonomy_type, payload
)
return self.normalize_target_object(result["data"])
class ToFarmOSAsset(ToFarmOS):
"""
Base class for asset data importer targeting the farmOS API.
"""
farmos_asset_type = None
def get_target_objects(self, **kwargs):
assets = self.farmos_client.asset.get(self.farmos_asset_type)
return assets["data"]
def get_target_object(self, key):
# fetch from cache, if applicable
if self.caches_target:
return super().get_target_object(key)
# okay now must fetch via API
if self.get_keys() != ["uuid"]:
raise ValueError("must use uuid key for this to work")
uuid = key[0]
try:
asset = self.farmos_client.asset.get_id(self.farmos_asset_type, str(uuid))
except requests.HTTPError as exc:
if exc.response.status_code == 404:
return None
raise
return asset["data"]
def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"):
return None
if self.dry_run:
return source_data
payload = self.get_asset_payload(source_data)
result = self.farmos_client.asset.send(self.farmos_asset_type, payload)
normal = self.normalize_target_object(result["data"])
normal["_new_object"] = result["data"]
return normal
def update_target_object(self, asset, source_data, target_data=None):
if self.dry_run:
return asset
payload = self.get_asset_payload(source_data)
payload["id"] = str(source_data["uuid"])
result = self.farmos_client.asset.send(self.farmos_asset_type, payload)
return self.normalize_target_object(result["data"])
def normalize_target_object(self, asset):
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
return {
"uuid": UUID(asset["id"]),
"asset_name": asset["attributes"]["name"],
"is_location": asset["attributes"]["is_location"],
"is_fixed": asset["attributes"]["is_fixed"],
"produces_eggs": asset["attributes"].get("produces_eggs"),
"notes": notes,
"archived": asset["attributes"]["archived"],
}
def get_asset_payload(self, source_data):
attrs = {}
if "asset_name" in self.fields:
attrs["name"] = source_data["asset_name"]
if "is_location" in self.fields:
attrs["is_location"] = source_data["is_location"]
if "is_fixed" in self.fields:
attrs["is_fixed"] = source_data["is_fixed"]
if "produces_eggs" in self.fields:
attrs["produces_eggs"] = source_data["produces_eggs"]
if "notes" in self.fields:
attrs["notes"] = {"value": source_data["notes"]}
if "archived" in self.fields:
attrs["archived"] = source_data["archived"]
payload = {"attributes": attrs}
return payload
class UnitImporter(ToFarmOSTaxonomy):
model_title = "Unit"
farmos_taxonomy_type = "unit"
class AnimalAssetImporter(ToFarmOSAsset):
model_title = "AnimalAsset"
farmos_asset_type = "animal"
supported_fields = [
"uuid",
"asset_name",
"animal_type_uuid",
"sex",
"is_sterile",
"produces_eggs",
"birthdate",
"notes",
"archived",
]
def normalize_target_object(self, animal):
data = super().normalize_target_object(animal)
data.update(
{
"animal_type_uuid": UUID(
animal["relationships"]["animal_type"]["data"]["id"]
),
"sex": animal["attributes"]["sex"],
"is_sterile": animal["attributes"]["is_sterile"],
"birthdate": self.normalize_datetime(animal["attributes"]["birthdate"]),
}
)
return data
def get_asset_payload(self, source_data):
payload = super().get_asset_payload(source_data)
attrs = {}
if "sex" in self.fields:
attrs["sex"] = source_data["sex"]
if "is_sterile" in self.fields:
attrs["is_sterile"] = source_data["is_sterile"]
if "birthdate" in self.fields:
attrs["birthdate"] = self.format_datetime(source_data["birthdate"])
rels = {}
if "animal_type_uuid" in self.fields:
rels["animal_type"] = {
"data": {
"id": str(source_data["animal_type_uuid"]),
"type": "taxonomy_term--animal_type",
}
}
payload["attributes"].update(attrs)
if rels:
payload.setdefault("relationships", {}).update(rels)
return payload
class AnimalTypeImporter(ToFarmOSTaxonomy):
model_title = "AnimalType"
farmos_taxonomy_type = "animal_type"
class GroupAssetImporter(ToFarmOSAsset):
model_title = "GroupAsset"
farmos_asset_type = "group"
supported_fields = [
"uuid",
"asset_name",
"produces_eggs",
"notes",
"archived",
]
class LandAssetImporter(ToFarmOSAsset):
model_title = "LandAsset"
farmos_asset_type = "land"
supported_fields = [
"uuid",
"asset_name",
"land_type_id",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_target_object(self, land):
data = super().normalize_target_object(land)
data.update(
{
"land_type_id": land["attributes"]["land_type"],
}
)
return data
def get_asset_payload(self, source_data):
payload = super().get_asset_payload(source_data)
attrs = {}
if "land_type_id" in self.fields:
attrs["land_type"] = source_data["land_type_id"]
if attrs:
payload["attributes"].update(attrs)
return payload
class PlantTypeImporter(ToFarmOSTaxonomy):
model_title = "PlantType"
farmos_taxonomy_type = "plant_type"
class PlantAssetImporter(ToFarmOSAsset):
model_title = "PlantAsset"
farmos_asset_type = "plant"
supported_fields = [
"uuid",
"asset_name",
"plant_type_uuids",
"notes",
"archived",
]
def normalize_target_object(self, plant):
data = super().normalize_target_object(plant)
data.update(
{
"plant_type_uuids": [
UUID(p["id"]) for p in plant["relationships"]["plant_type"]["data"]
],
}
)
return data
def get_asset_payload(self, source_data):
payload = super().get_asset_payload(source_data)
attrs = {}
if "sex" in self.fields:
attrs["sex"] = source_data["sex"]
if "is_sterile" in self.fields:
attrs["is_sterile"] = source_data["is_sterile"]
if "birthdate" in self.fields:
attrs["birthdate"] = self.format_datetime(source_data["birthdate"])
rels = {}
if "plant_type_uuids" in self.fields:
rels["plant_type"] = {"data": []}
for uuid in source_data["plant_type_uuids"]:
rels["plant_type"]["data"].append(
{
"id": str(uuid),
"type": "taxonomy_term--plant_type",
}
)
payload["attributes"].update(attrs)
if rels:
payload.setdefault("relationships", {}).update(rels)
return payload
class StructureAssetImporter(ToFarmOSAsset):
model_title = "StructureAsset"
farmos_asset_type = "structure"
supported_fields = [
"uuid",
"asset_name",
"structure_type_id",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_target_object(self, structure):
data = super().normalize_target_object(structure)
data.update(
{
"structure_type_id": structure["attributes"]["structure_type"],
}
)
return data
def get_asset_payload(self, source_data):
payload = super().get_asset_payload(source_data)
attrs = {}
if "structure_type_id" in self.fields:
attrs["structure_type"] = source_data["structure_type_id"]
if attrs:
payload["attributes"].update(attrs)
return payload
##############################
# quantity importers
##############################
class ToFarmOSQuantity(ToFarmOS):
"""
Base class for quantity data importer targeting the farmOS API.
"""
farmos_quantity_type = None
supported_fields = [
"uuid",
"measure",
"value_numerator",
"value_denominator",
"label",
"quantity_type_uuid",
"unit_uuid",
]
def get_target_objects(self, **kwargs):
return list(
self.farmos_client.resource.iterate("quantity", self.farmos_quantity_type)
)
def get_target_object(self, key):
# fetch from cache, if applicable
if self.caches_target:
return super().get_target_object(key)
# okay now must fetch via API
if self.get_keys() != ["uuid"]:
raise ValueError("must use uuid key for this to work")
uuid = key[0]
try:
qty = self.farmos_client.resource.get_id(
"quantity", self.farmos_quantity_type, str(uuid)
)
except requests.HTTPError as exc:
if exc.response.status_code == 404:
return None
raise
return qty["data"]
def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"):
return None
if self.dry_run:
return source_data
payload = self.get_quantity_payload(source_data)
result = self.farmos_client.resource.send(
"quantity", self.farmos_quantity_type, payload
)
normal = self.normalize_target_object(result["data"])
normal["_new_object"] = result["data"]
return normal
def update_target_object(self, quantity, source_data, target_data=None):
if self.dry_run:
return quantity
payload = self.get_quantity_payload(source_data)
payload["id"] = str(source_data["uuid"])
result = self.farmos_client.resource.send(
"quantity", self.farmos_quantity_type, payload
)
return self.normalize_target_object(result["data"])
def normalize_target_object(self, qty):
result = {
"uuid": UUID(qty["id"]),
"measure": qty["attributes"]["measure"],
"value_numerator": qty["attributes"]["value"]["numerator"],
"value_denominator": qty["attributes"]["value"]["denominator"],
"label": qty["attributes"]["label"],
"quantity_type_uuid": UUID(
qty["relationships"]["quantity_type"]["data"]["id"]
),
"unit_uuid": None,
}
if unit := qty["relationships"]["units"]["data"]:
result["unit_uuid"] = UUID(unit["id"])
return result
def get_quantity_payload(self, source_data):
attrs = {}
if "measure" in self.fields:
attrs["measure"] = source_data["measure"]
if "value_numerator" in self.fields and "value_denominator" in self.fields:
attrs["value"] = {
"numerator": source_data["value_numerator"],
"denominator": source_data["value_denominator"],
}
if "label" in self.fields:
attrs["label"] = source_data["label"]
rels = {}
if "quantity_type_uuid" in self.fields:
rels["quantity_type"] = {
"data": {
"id": str(source_data["quantity_type_uuid"]),
"type": "quantity_type--quantity_type",
}
}
if "unit_uuid" in self.fields:
rels["units"] = {
"data": {
"id": str(source_data["unit_uuid"]),
"type": "taxonomy_term--unit",
}
}
payload = {"attributes": attrs, "relationships": rels}
return payload
class StandardQuantityImporter(ToFarmOSQuantity):
model_title = "StandardQuantity"
farmos_quantity_type = "standard"
##############################
# log importers
##############################
class ToFarmOSLog(ToFarmOS):
"""
Base class for log data importer targeting the farmOS API.
"""
farmos_log_type = None
supported_fields = [
"uuid",
"name",
"timestamp",
"is_movement",
"is_group_assignment",
"status",
"notes",
"quick",
"assets",
"quantities",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.normal = self.app.get_normalizer(self.farmos_client)
def get_target_objects(self, **kwargs):
result = self.farmos_client.log.get(self.farmos_log_type)
return result["data"]
def get_target_object(self, key):
# fetch from cache, if applicable
if self.caches_target:
return super().get_target_object(key)
# okay now must fetch via API
if self.get_keys() != ["uuid"]:
raise ValueError("must use uuid key for this to work")
uuid = key[0]
try:
log = self.farmos_client.log.get_id(self.farmos_log_type, str(uuid))
except requests.HTTPError as exc:
if exc.response.status_code == 404:
return None
raise
return log["data"]
def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"):
return None
if self.dry_run:
return source_data
payload = self.get_log_payload(source_data)
result = self.farmos_client.log.send(self.farmos_log_type, payload)
normal = self.normalize_target_object(result["data"])
normal["_new_object"] = result["data"]
return normal
def update_target_object(self, asset, source_data, target_data=None):
if self.dry_run:
return asset
payload = self.get_log_payload(source_data)
payload["id"] = str(source_data["uuid"])
result = self.farmos_client.log.send(self.farmos_log_type, payload)
return self.normalize_target_object(result["data"])
def normalize_target_object(self, log):
normal = self.normal.normalize_farmos_log(log)
return {
"uuid": UUID(normal["uuid"]),
"name": normal["name"],
"timestamp": self.app.make_utc(normal["timestamp"]),
"is_movement": normal["is_movement"],
"is_group_assignment": normal["is_group_assignment"],
"status": normal["status"],
"notes": normal["notes"],
"quick": normal["quick"],
"assets": [(a["asset_type"], UUID(a["uuid"])) for a in normal["assets"]],
"quantities": [UUID(uuid) for uuid in normal["quantity_uuids"]],
}
def get_log_payload(self, source_data):
attrs = {}
if "name" in self.fields:
attrs["name"] = source_data["name"]
if "timestamp" in self.fields:
attrs["timestamp"] = self.format_datetime(source_data["timestamp"])
if "is_movement" in self.fields:
attrs["is_movement"] = source_data["is_movement"]
if "is_group_assignment" in self.fields:
attrs["is_group_assignment"] = source_data["is_group_assignment"]
if "status" in self.fields:
attrs["status"] = source_data["status"]
if "notes" in self.fields:
attrs["notes"] = {"value": source_data["notes"]}
if "quick" in self.fields:
attrs["quick"] = source_data["quick"]
rels = {}
if "assets" in self.fields:
assets = []
for asset_type, uuid in source_data["assets"]:
assets.append(
{
"type": f"asset--{asset_type}",
"id": str(uuid),
}
)
rels["asset"] = {"data": assets}
if "quantities" in self.fields:
quantities = []
for uuid in source_data["quantities"]:
quantities.append(
{
# TODO: support other quantity types
"type": "quantity--standard",
"id": str(uuid),
}
)
rels["quantity"] = {"data": quantities}
payload = {"attributes": attrs, "relationships": rels}
return payload
class ActivityLogImporter(ToFarmOSLog):
model_title = "ActivityLog"
farmos_log_type = "activity"
class HarvestLogImporter(ToFarmOSLog):
model_title = "HarvestLog"
farmos_log_type = "harvest"
class MedicalLogImporter(ToFarmOSLog):
model_title = "MedicalLog"
farmos_log_type = "medical"
def get_supported_fields(self):
fields = list(super().get_supported_fields())
fields.extend(
[
"vet",
]
)
return fields
def normalize_target_object(self, log):
data = super().normalize_target_object(log)
data.update(
{
"vet": log["attributes"]["vet"],
}
)
return data
def get_log_payload(self, source_data):
payload = super().get_log_payload(source_data)
if "vet" in self.fields:
payload["attributes"]["vet"] = source_data["vet"]
return payload
class ObservationLogImporter(ToFarmOSLog):
model_title = "ObservationLog"
farmos_log_type = "observation"

View file

@ -0,0 +1,482 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttaFarm farmOS data export
"""
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
from wuttasync.importing import ImportHandler, FromWuttaHandler, FromWutta, Orientation
from wuttafarm.db import model
from wuttafarm.farmos import importing as farmos_importing
class FromWuttaFarmHandler(FromWuttaHandler):
"""
Base class for import handler targeting WuttaFarm
"""
source_key = "wuttafarm"
class ToFarmOSHandler(ImportHandler):
"""
Base class for export handlers using CSV file(s) as data target.
"""
target_key = "farmos"
generic_target_title = "farmOS"
# TODO: a lot of duplication to cleanup here; see FromFarmOSHandler
def begin_target_transaction(self, client=None):
"""
Establish the farmOS API client.
"""
if client:
self.farmos_client = client
else:
token = self.get_farmos_oauth2_token()
self.farmos_client = self.app.get_farmos_client(token=token)
self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
def get_farmos_oauth2_token(self):
client_id = self.config.get(
"farmos.oauth2.importing.client_id", default="wuttafarm"
)
client_secret = self.config.require("farmos.oauth2.importing.client_secret")
scope = self.config.get("farmos.oauth2.importing.scope", default="farm_manager")
client = BackendApplicationClient(client_id=client_id)
oauth = OAuth2Session(client=client)
return oauth.fetch_token(
token_url=self.app.get_farmos_url("/oauth/token"),
include_client_id=True,
client_secret=client_secret,
scope=scope,
)
def get_importer_kwargs(self, key, **kwargs):
kwargs = super().get_importer_kwargs(key, **kwargs)
kwargs["farmos_client"] = self.farmos_client
kwargs["farmos_4x"] = self.farmos_4x
return kwargs
class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
"""
Handler for WuttaFarm farmOS API export.
"""
orientation = Orientation.EXPORT
def define_importers(self):
""" """
importers = super().define_importers()
importers["LandAsset"] = LandAssetImporter
importers["StructureAsset"] = StructureAssetImporter
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
importers["PlantType"] = PlantTypeImporter
importers["PlantAsset"] = PlantAssetImporter
importers["Unit"] = UnitImporter
importers["StandardQuantity"] = StandardQuantityImporter
importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter
importers["MedicalLog"] = MedicalLogImporter
importers["ObservationLog"] = ObservationLogImporter
return importers
class FromWuttaFarm(FromWutta):
drupal_internal_id_field = "drupal_internal__id"
def create_target_object(self, key, source_data):
obj = super().create_target_object(key, source_data)
if obj is None:
return None
if not self.dry_run:
# set farmOS, Drupal key fields in WuttaFarm
api_object = obj["_new_object"]
wf_object = source_data["_src_object"]
wf_object.farmos_uuid = obj["uuid"]
wf_object.drupal_id = api_object["attributes"][
self.drupal_internal_id_field
]
return obj
class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImporter):
"""
WuttaFarm farmOS API exporter for Animal Assets
"""
source_model_class = model.AnimalAsset
supported_fields = [
"uuid",
"asset_name",
"animal_type_uuid",
"sex",
"is_sterile",
"produces_eggs",
"birthdate",
"notes",
"archived",
]
def normalize_source_object(self, animal):
return {
"uuid": animal.farmos_uuid or self.app.make_true_uuid(),
"asset_name": animal.asset_name,
"animal_type_uuid": animal.animal_type.farmos_uuid,
"sex": animal.sex,
"is_sterile": animal.is_sterile,
"produces_eggs": animal.produces_eggs,
"birthdate": animal.birthdate,
"notes": animal.notes,
"archived": animal.archived,
"_src_object": animal,
}
class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporter):
"""
WuttaFarm farmOS API exporter for Animal Types
"""
source_model_class = model.AnimalType
supported_fields = [
"uuid",
"name",
]
drupal_internal_id_field = "drupal_internal__tid"
def normalize_source_object(self, animal_type):
return {
"uuid": animal_type.farmos_uuid or self.app.make_true_uuid(),
"name": animal_type.name,
"_src_object": animal_type,
}
class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter):
"""
WuttaFarm farmOS API exporter for Units
"""
source_model_class = model.Unit
supported_fields = [
"uuid",
"name",
]
drupal_internal_id_field = "drupal_internal__tid"
def normalize_source_object(self, unit):
return {
"uuid": unit.farmos_uuid or self.app.make_true_uuid(),
"name": unit.name,
"_src_object": unit,
}
class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter):
"""
WuttaFarm farmOS API exporter for Group Assets
"""
source_model_class = model.GroupAsset
supported_fields = [
"uuid",
"asset_name",
"produces_eggs",
"notes",
"archived",
]
def normalize_source_object(self, group):
return {
"uuid": group.farmos_uuid or self.app.make_true_uuid(),
"asset_name": group.asset_name,
"produces_eggs": group.produces_eggs,
"notes": group.notes,
"archived": group.archived,
"_src_object": group,
}
class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter):
"""
WuttaFarm farmOS API exporter for Land Assets
"""
source_model_class = model.LandAsset
supported_fields = [
"uuid",
"asset_name",
"land_type_id",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_source_object(self, land):
return {
"uuid": land.farmos_uuid or self.app.make_true_uuid(),
"asset_name": land.asset_name,
"land_type_id": land.land_type.drupal_id,
"is_location": land.is_location,
"is_fixed": land.is_fixed,
"notes": land.notes,
"archived": land.archived,
"_src_object": land,
}
class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter):
"""
WuttaFarm farmOS API exporter for Plant Types
"""
source_model_class = model.PlantType
supported_fields = [
"uuid",
"name",
]
drupal_internal_id_field = "drupal_internal__tid"
def normalize_source_object(self, plant_type):
return {
"uuid": plant_type.farmos_uuid or self.app.make_true_uuid(),
"name": plant_type.name,
"_src_object": plant_type,
}
class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter):
"""
WuttaFarm farmOS API exporter for Plant Assets
"""
source_model_class = model.PlantAsset
supported_fields = [
"uuid",
"asset_name",
"plant_type_uuids",
"notes",
"archived",
]
def normalize_source_object(self, plant):
return {
"uuid": plant.farmos_uuid or self.app.make_true_uuid(),
"asset_name": plant.asset_name,
"plant_type_uuids": [t.plant_type.farmos_uuid for t in plant._plant_types],
"notes": plant.notes,
"archived": plant.archived,
"_src_object": plant,
}
class StructureAssetImporter(
FromWuttaFarm, farmos_importing.model.StructureAssetImporter
):
"""
WuttaFarm farmOS API exporter for Structure Assets
"""
source_model_class = model.StructureAsset
supported_fields = [
"uuid",
"asset_name",
"structure_type_id",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_source_object(self, structure):
return {
"uuid": structure.farmos_uuid or self.app.make_true_uuid(),
"asset_name": structure.asset_name,
"structure_type_id": structure.structure_type.drupal_id,
"is_location": structure.is_location,
"is_fixed": structure.is_fixed,
"notes": structure.notes,
"archived": structure.archived,
"_src_object": structure,
}
##############################
# quantity importers
##############################
class FromWuttaFarmQuantity(FromWuttaFarm):
"""
Base class for WuttaFarm -> farmOS quantity importers
"""
supported_fields = [
"uuid",
"measure",
"value_numerator",
"value_denominator",
"label",
"quantity_type_uuid",
"unit_uuid",
]
def normalize_source_object(self, qty):
return {
"uuid": qty.farmos_uuid or self.app.make_true_uuid(),
"measure": qty.measure_id,
"value_numerator": qty.value_numerator,
"value_denominator": qty.value_denominator,
"label": qty.label,
"quantity_type_uuid": qty.quantity_type.farmos_uuid,
"unit_uuid": qty.units.farmos_uuid,
"_src_object": qty,
}
class StandardQuantityImporter(
FromWuttaFarmQuantity, farmos_importing.model.StandardQuantityImporter
):
"""
WuttaFarm farmOS API exporter for Standard Quantities
"""
source_model_class = model.StandardQuantity
##############################
# log importers
##############################
class FromWuttaFarmLog(FromWuttaFarm):
"""
Base class for WuttaFarm -> farmOS log importers
"""
supported_fields = [
"uuid",
"name",
"timestamp",
"is_movement",
"is_group_assignment",
"status",
"notes",
"quick",
"assets",
"quantities",
]
def normalize_source_object(self, log):
return {
"uuid": log.farmos_uuid or self.app.make_true_uuid(),
"name": log.message,
"timestamp": log.timestamp,
"is_movement": log.is_movement,
"is_group_assignment": log.is_group_assignment,
"status": log.status,
"notes": log.notes,
"quick": self.config.parse_list(log.quick) if log.quick else [],
"assets": [(a.asset_type, a.farmos_uuid) for a in log.assets],
"quantities": [qty.farmos_uuid for qty in log.quantities],
"_src_object": log,
}
class ActivityLogImporter(FromWuttaFarmLog, farmos_importing.model.ActivityLogImporter):
"""
WuttaFarm farmOS API exporter for Activity Logs
"""
source_model_class = model.ActivityLog
class HarvestLogImporter(FromWuttaFarmLog, farmos_importing.model.HarvestLogImporter):
"""
WuttaFarm farmOS API exporter for Harvest Logs
"""
source_model_class = model.HarvestLog
class MedicalLogImporter(FromWuttaFarmLog, farmos_importing.model.MedicalLogImporter):
"""
WuttaFarm farmOS API exporter for Medical Logs
"""
source_model_class = model.MedicalLog
def get_supported_fields(self):
fields = list(super().get_supported_fields())
fields.extend(
[
"vet",
]
)
return fields
def normalize_source_object(self, log):
data = super().normalize_source_object(log)
data.update(
{
"vet": log.vet,
}
)
return data
class ObservationLogImporter(
FromWuttaFarmLog, farmos_importing.model.ObservationLogImporter
):
"""
WuttaFarm farmOS API exporter for Observation Logs
"""
source_model_class = model.ObservationLog

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Importing data to WuttaFarm
"""

File diff suppressed because it is too large Load diff

292
src/wuttafarm/normal.py Normal file
View file

@ -0,0 +1,292 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Data normalizer for WuttaFarm / farmOS
"""
import datetime
from wuttjamaican.app import GenericHandler
class Normalizer(GenericHandler):
"""
Base class and default implementation for the global data
normalizer. This should be used for normalizing records from
WuttaFarm and/or farmOS.
The point here is to have a single place to put the normalization
logic, and let it be another thing which can be customized via
subclass.
"""
_farmos_units = None
_farmos_measures = None
def __init__(self, config, farmos_client=None):
super().__init__(config)
self.farmos_client = farmos_client
def get_farmos_measures(self):
if self._farmos_measures:
return self._farmos_measures
measures = {}
response = self.farmos_client.session.get(
self.app.get_farmos_url("/api/quantity/standard/resource/schema")
)
response.raise_for_status()
data = response.json()
for measure in data["definitions"]["attributes"]["properties"]["measure"][
"oneOf"
]:
measures[measure["const"]] = measure["title"]
self._farmos_measures = measures
return self._farmos_measures
def get_farmos_measure_name(self, measure_id):
measures = self.get_farmos_measures()
return measures[measure_id]
def get_farmos_unit(self, uuid):
units = self.get_farmos_units()
return units[uuid]
def get_farmos_units(self):
if self._farmos_units:
return self._farmos_units
units = {}
result = self.farmos_client.resource.get("taxonomy_term", "unit")
for unit in result["data"]:
units[unit["id"]] = unit
self._farmos_units = units
return self._farmos_units
def normalize_farmos_asset(self, asset, included={}):
""" """
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
owner_objects = []
owner_uuids = []
if relationships := asset.get("relationships"):
if owners := relationships.get("owner"):
for user in owners["data"]:
user_uuid = user["id"]
owner_uuids.append(user_uuid)
if user := included.get(user_uuid):
owner_objects.append(
{
"uuid": user["id"],
"name": user["attributes"]["name"],
}
)
return {
"uuid": asset["id"],
"drupal_id": asset["attributes"]["drupal_internal__id"],
"asset_name": asset["attributes"]["name"],
"is_location": asset["attributes"]["is_location"],
"is_fixed": asset["attributes"]["is_fixed"],
"archived": asset["attributes"]["archived"],
"notes": notes,
"owners": owner_objects,
"owner_uuids": owner_uuids,
}
def normalize_farmos_log(self, log, included={}):
if timestamp := log["attributes"]["timestamp"]:
timestamp = datetime.datetime.fromisoformat(timestamp)
timestamp = self.app.localtime(timestamp)
if notes := log["attributes"]["notes"]:
notes = notes["value"]
log_type_object = {}
log_type_uuid = None
asset_objects = []
group_objects = []
group_uuids = []
quantity_objects = []
quantity_uuids = []
location_objects = []
location_uuids = []
owner_objects = []
owner_uuids = []
if relationships := log.get("relationships"):
if log_type := relationships.get("log_type"):
log_type_uuid = log_type["data"]["id"]
if log_type := included.get(log_type_uuid):
log_type_object = {
"uuid": log_type["id"],
"name": log_type["attributes"]["label"],
}
if assets := relationships.get("asset"):
for asset in assets["data"]:
asset_object = {
"uuid": asset["id"],
"type": asset["type"],
"asset_type": asset["type"].split("--")[1],
}
if asset := included.get(asset["id"]):
attrs = asset["attributes"]
rels = asset["relationships"]
asset_object.update(
{
"drupal_id": attrs["drupal_internal__id"],
"name": attrs["name"],
"is_location": attrs["is_location"],
"is_fixed": attrs["is_fixed"],
"archived": attrs["archived"],
"notes": attrs["notes"],
}
)
asset_objects.append(asset_object)
if groups := relationships.get("group"):
for group in groups["data"]:
group_uuid = group["id"]
group_uuids.append(group_uuid)
group_object = {
"uuid": group["id"],
"type": group["type"],
"asset_type": group["type"].split("--")[1],
}
if group := included.get(group_uuid):
attrs = group["attributes"]
rels = group["relationships"]
group_object.update(
{
"drupal_id": attrs["drupal_internal__id"],
"name": attrs["name"],
"is_location": attrs["is_location"],
"is_fixed": attrs["is_fixed"],
"archived": attrs["archived"],
"notes": attrs["notes"],
}
)
group_objects.append(group_object)
if locations := relationships.get("location"):
for location in locations["data"]:
location_uuid = location["id"]
location_uuids.append(location_uuid)
location_object = {
"uuid": location["id"],
"type": location["type"],
"asset_type": location["type"].split("--")[1],
}
if location := included.get(location_uuid):
attrs = location["attributes"]
rels = location["relationships"]
location_object.update(
{
"drupal_id": attrs["drupal_internal__id"],
"name": attrs["name"],
"is_location": attrs["is_location"],
"is_fixed": attrs["is_fixed"],
"archived": attrs["archived"],
"notes": attrs["notes"],
}
)
location_objects.append(location_object)
if quantities := relationships.get("quantity"):
for quantity in quantities["data"]:
quantity_uuid = quantity["id"]
quantity_uuids.append(quantity_uuid)
if quantity := included.get(quantity_uuid):
attrs = quantity["attributes"]
rels = quantity["relationships"]
value = attrs["value"]
unit_uuid = rels["units"]["data"]["id"]
unit = self.get_farmos_unit(unit_uuid)
measure_id = attrs["measure"]
quantity_objects.append(
{
"uuid": quantity["id"],
"drupal_id": attrs["drupal_internal__id"],
"quantity_type_uuid": rels["quantity_type"]["data"][
"id"
],
"quantity_type_id": rels["quantity_type"]["data"][
"meta"
]["drupal_internal__target_id"],
"measure_id": measure_id,
"measure_name": self.get_farmos_measure_name(
measure_id
),
"value_numerator": value["numerator"],
"value_decimal": value["decimal"],
"value_denominator": value["denominator"],
"unit_uuid": unit_uuid,
"unit_name": unit["attributes"]["name"],
}
)
if owners := relationships.get("owner"):
for user in owners["data"]:
user_uuid = user["id"]
owner_uuids.append(user_uuid)
if user := included.get(user_uuid):
owner_objects.append(
{
"uuid": user["id"],
"name": user["attributes"]["name"],
}
)
return {
"uuid": log["id"],
"drupal_id": log["attributes"]["drupal_internal__id"],
"log_type_uuid": log_type_uuid,
"log_type": log_type_object,
"name": log["attributes"]["name"],
"timestamp": timestamp,
"assets": asset_objects,
"groups": group_objects,
"group_uuids": group_uuids,
"quantities": quantity_objects,
"quantity_uuids": quantity_uuids,
"is_group_assignment": log["attributes"]["is_group_assignment"],
"is_movement": log["attributes"]["is_movement"],
"quick": log["attributes"]["quick"],
"status": log["attributes"]["status"],
"notes": notes,
"locations": location_objects,
"location_uuids": location_uuids,
"owners": owner_objects,
"owner_uuids": owner_uuids,
# TODO: should we do this here or make caller do it?
"vet": log["attributes"].get("vet"),
}

37
src/wuttafarm/util.py Normal file
View file

@ -0,0 +1,37 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
misc. utilities
"""
from collections import OrderedDict
def get_log_type_enum(config, session=None):
app = config.get_app()
model = app.model
log_types = OrderedDict()
with app.short_session(session=session) as sess:
query = sess.query(model.LogType).order_by(model.LogType.name)
for log_type in query:
log_types[log_type.drupal_id] = log_type.name
return log_types

View file

@ -40,6 +40,15 @@ def main(global_config, **settings):
"wuttaweb:templates",
],
)
settings.setdefault(
"pyramid_deform.template_search_path",
" ".join(
[
"wuttafarm.web:templates/deform",
"wuttaweb:templates/deform",
]
),
)
# make config objects
wutta_config = base.make_wutta_config(settings)

View file

@ -27,8 +27,197 @@ import json
import colander
from wuttaweb.db import Session
from wuttaweb.forms.schema import ObjectRef, WuttaSet
from wuttaweb.forms.widgets import NotesWidget
class AnimalTypeType(colander.SchemaType):
class AnimalTypeRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttafarm.db.model.animals.AnimalType` reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.AnimalType
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.name)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
animal_type = obj
return self.request.route_url("animal_types.view", uuid=animal_type.uuid)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import AnimalTypeRefWidget
kwargs["factory"] = AnimalTypeRefWidget
return super().widget_maker(**kwargs)
class LogQuick(WuttaSet):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import LogQuickWidget
return LogQuickWidget(**kwargs)
class LogRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttafarm.db.model.log.Log` reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.Log
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.message)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
log = obj
return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
class FarmOSUnitRef(colander.SchemaType):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSUnitRefWidget
return FarmOSUnitRefWidget(**kwargs)
class FarmOSRef(colander.SchemaType):
def __init__(self, request, route_prefix, *args, **kwargs):
self.values = kwargs.pop("values", None)
super().__init__(*args, **kwargs)
self.request = request
self.route_prefix = route_prefix
def get_values(self):
if callable(self.values):
self.values = self.values()
return self.values
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
# nb. keep a ref to this for later use
node.model_instance = appstruct
# serialize to PK as string
return appstruct["uuid"]
def deserialize(self, node, cstruct):
if not cstruct:
return colander.null
# nb. deserialize to PK string, not dict
return cstruct
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSRefWidget
if not kwargs.get("readonly"):
if "values" not in kwargs:
if values := self.get_values():
kwargs["values"] = values
return FarmOSRefWidget(self.request, self.route_prefix, **kwargs)
class FarmOSRefs(WuttaSet):
def __init__(self, request, route_prefix, *args, **kwargs):
super().__init__(request, *args, **kwargs)
self.route_prefix = route_prefix
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSRefsWidget
return FarmOSRefsWidget(self.request, self.route_prefix, **kwargs)
class FarmOSAssetRefs(WuttaSet):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSAssetRefsWidget
return FarmOSAssetRefsWidget(self.request, **kwargs)
class FarmOSLocationRefs(WuttaSet):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSLocationRefsWidget
return FarmOSLocationRefsWidget(self.request, **kwargs)
class FarmOSQuantityRefs(WuttaSet):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSQuantityRefsWidget
return FarmOSQuantityRefsWidget(**kwargs)
class FarmOSPlantTypes(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -42,9 +231,61 @@ class AnimalTypeType(colander.SchemaType):
def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
""" """
from wuttafarm.web.forms.widgets import AnimalTypeWidget
from wuttafarm.web.forms.widgets import FarmOSPlantTypesWidget
return AnimalTypeWidget(self.request, **kwargs)
return FarmOSPlantTypesWidget(self.request, **kwargs)
class LandTypeRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttafarm.db.model.land.LandType` reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.LandType
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.name)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
land_type = obj
return self.request.route_url("land_types.view", uuid=land_type.uuid)
class PlantTypeRefs(WuttaSet):
"""
Schema type for Plant Types field (on a Plant Asset).
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
return [uuid.hex for uuid in appstruct]
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import PlantTypeRefsWidget
model = self.app.model
session = Session()
if "values" not in kwargs:
plant_types = (
session.query(model.PlantType).order_by(model.PlantType.name).all()
)
values = [(pt.uuid.hex, str(pt)) for pt in plant_types]
kwargs["values"] = values
return PlantTypeRefsWidget(self.request, **kwargs)
class StructureType(colander.SchemaType):
@ -66,6 +307,52 @@ class StructureType(colander.SchemaType):
return StructureWidget(self.request, **kwargs)
class StructureTypeRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttafarm.db.model.structures.Structure` reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.StructureType
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.name)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
structure_type = obj
return self.request.route_url("structure_types.view", uuid=structure_type.uuid)
class UnitRef(ObjectRef):
"""
Custom schema type for a :class:`~wuttafarm.db.model.units.Unit`
reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self):
model = self.app.model
return model.Unit
def sort_query(self, query):
return query.order_by(self.model_class.name)
def get_object_url(self, unit):
return self.request.route_url("units.view", uuid=unit.uuid)
class UsersType(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
@ -83,3 +370,93 @@ class UsersType(colander.SchemaType):
from wuttafarm.web.forms.widgets import UsersWidget
return UsersWidget(self.request, **kwargs)
class AssetParentRefs(WuttaSet):
"""
Schema type for Parents field which references assets.
"""
def serialize(self, node, appstruct):
if not appstruct:
appstruct = []
uuids = [u.hex for u in appstruct]
return json.dumps(uuids)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import AssetParentRefsWidget
return AssetParentRefsWidget(self.request, **kwargs)
class AssetRefs(WuttaSet):
"""
Schema type for Assets field (on a Log record)
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
return {asset.uuid for asset in appstruct}
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import AssetRefsWidget
return AssetRefsWidget(self.request, **kwargs)
class LogQuantityRefs(WuttaSet):
"""
Schema type for Quantities field (on a Log record)
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
return {qty.uuid for qty in appstruct}
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import LogQuantityRefsWidget
return LogQuantityRefsWidget(self.request, **kwargs)
class OwnerRefs(WuttaSet):
"""
Schema type for Owners field (on a Log record)
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
return {user.uuid for user in appstruct}
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import OwnerRefsWidget
return OwnerRefsWidget(self.request, **kwargs)
class Notes(colander.String):
"""
Custom schema type for "note" fields.
"""
def serialize(self, node, appstruct):
""" """
if not appstruct:
return colander.null
return super().serialize(node, appstruct)
def widget_maker(self, **kwargs):
"""
Construct a default widget for the field.
:returns: Instance of
:class:`~wuttaweb.forms.widgets.NotesWidget`.
"""
return NotesWidget(**kwargs)

View file

@ -26,9 +26,14 @@ Custom form widgets for WuttaFarm
import json
import colander
from deform.widget import Widget
from deform.widget import Widget, SelectWidget, sequence_types, _normalize_choices
from webhelpers2.html import HTML, tags
from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget
from wuttaweb.db import Session
from wuttafarm.web.util import render_quantity_objects
class ImageWidget(Widget):
"""
@ -51,9 +56,88 @@ class ImageWidget(Widget):
return super().serialize(field, cstruct, **kw)
class AnimalTypeWidget(Widget):
class LogQuickWidget(Widget):
"""
Widget to display an "animal type" field.
Widget to display an image URL for a record.
"""
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
items = []
for quick in json.loads(cstruct):
items.append(HTML.tag("li", c=quick))
return HTML.tag("ul", c=items)
return super().serialize(field, cstruct, **kw)
class FarmOSRefWidget(SelectWidget):
"""
Generic widget to display "any reference field" - as a link to
view the farmOS record it references. Only used by the farmOS
direct API views.
"""
def __init__(self, request, route_prefix, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.route_prefix = route_prefix
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
try:
obj = json.loads(cstruct)
except json.JSONDecodeError:
name = dict(self.values)[cstruct]
obj = {"uuid": cstruct, "name": name}
return tags.link_to(
obj["name"],
self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]),
)
return super().serialize(field, cstruct, **kw)
class FarmOSRefsWidget(Widget):
def __init__(self, request, route_prefix, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.route_prefix = route_prefix
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
links = []
for obj in json.loads(cstruct):
url = self.request.route_url(
f"{self.route_prefix}.view", uuid=obj["uuid"]
)
links.append(HTML.tag("li", c=tags.link_to(obj["name"], url)))
return HTML.tag("ul", c=links)
return super().serialize(field, cstruct, **kw)
class FarmOSAssetRefsWidget(Widget):
"""
Widget to display a "Assets" field for an asset.
"""
def __init__(self, request, *args, **kwargs):
@ -67,17 +151,187 @@ class AnimalTypeWidget(Widget):
if cstruct in (colander.null, None):
return HTML.tag("span")
animal_type = json.loads(cstruct)
return tags.link_to(
animal_type["name"],
self.request.route_url(
"farmos_animal_types.view", uuid=animal_type["uuid"]
),
)
assets = []
for asset in json.loads(cstruct):
url = self.request.route_url(
f"farmos_{asset['asset_type']}_assets.view", uuid=asset["uuid"]
)
assets.append(HTML.tag("li", c=tags.link_to(asset["name"], url)))
return HTML.tag("ul", c=assets)
return super().serialize(field, cstruct, **kw)
class FarmOSLocationRefsWidget(Widget):
"""
Widget to display a "Locations" field for an asset.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
locations = []
for location in json.loads(cstruct):
asset_type = location["type"].split("--")[1]
url = self.request.route_url(
f"farmos_{asset_type}_assets.view", uuid=location["uuid"]
)
locations.append(HTML.tag("li", c=tags.link_to(location["name"], url)))
return HTML.tag("ul", c=locations)
return super().serialize(field, cstruct, **kw)
class FarmOSQuantityRefsWidget(Widget):
"""
Widget to display a "Quantities" field for a log.
"""
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
quantities = json.loads(cstruct)
return render_quantity_objects(quantities)
return super().serialize(field, cstruct, **kw)
class FarmOSUnitRefWidget(Widget):
"""
Widget to display a "Units" field for a quantity.
"""
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
unit = json.loads(cstruct)
return unit["name"]
return super().serialize(field, cstruct, **kw)
class FarmOSPlantTypesWidget(Widget):
"""
Widget to display a farmOS "plant types" field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
links = []
for plant_type in json.loads(cstruct):
link = tags.link_to(
plant_type["name"],
self.request.route_url(
"farmos_plant_types.view", uuid=plant_type["uuid"]
),
)
links.append(HTML.tag("li", c=link))
return HTML.tag("ul", c=links)
return super().serialize(field, cstruct, **kw)
class PlantTypeRefsWidget(Widget):
"""
Widget for Plant Types field (on a Plant Asset).
"""
template = "planttyperefs"
values = ()
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
if cstruct in (colander.null, None):
cstruct = ()
if readonly := kw.get("readonly", self.readonly):
items = []
plant_types = (
session.query(model.PlantType)
.filter(model.PlantType.uuid.in_(cstruct))
.order_by(model.PlantType.name)
.all()
)
for plant_type in plant_types:
items.append(
HTML.tag(
"li",
c=tags.link_to(
str(plant_type),
self.request.route_url(
"plant_types.view", uuid=plant_type.uuid
),
),
)
)
return HTML.tag("ul", c=items)
values = kw.get("values", self.values)
if not isinstance(values, sequence_types):
raise TypeError("Values must be a sequence type (list, tuple, or range).")
kw["values"] = _normalize_choices(values)
tmpl_values = self.get_template_values(field, cstruct, kw)
return field.renderer(self.template, **tmpl_values)
def get_template_values(self, field, cstruct, kw):
""" """
values = super().get_template_values(field, cstruct, kw)
values["js_values"] = json.dumps(values["values"])
if self.request.has_perm("plant_types.create"):
values["can_create"] = True
return values
def deserialize(self, field, pstruct):
""" """
if not pstruct:
return colander.null
return set(pstruct.split(","))
class StructureWidget(Widget):
"""
Widget to display a "structure" field.
@ -98,7 +352,7 @@ class StructureWidget(Widget):
return tags.link_to(
structure["name"],
self.request.route_url(
"farmos_structures.view", uuid=structure["uuid"]
"farmos_structure_assets.view", uuid=structure["uuid"]
),
)
@ -132,3 +386,152 @@ class UsersWidget(Widget):
return HTML.tag("ul", c=items)
return super().serialize(field, cstruct, **kw)
##############################
# native data widgets
##############################
class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Parents field which references assets.
"""
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
readonly = kw.get("readonly", self.readonly)
if readonly:
parents = []
for uuid in json.loads(cstruct):
parent = session.get(model.Asset, uuid)
parents.append(
HTML.tag(
"li",
c=tags.link_to(
str(parent),
self.request.route_url(
f"{parent.asset_type}_assets.view", uuid=parent.uuid
),
),
)
)
return HTML.tag("ul", c=parents)
return super().serialize(field, cstruct, **kw)
class AssetRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Assets field (of various kinds).
"""
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
readonly = kw.get("readonly", self.readonly)
if readonly:
assets = []
for uuid in cstruct or []:
asset = session.get(model.Asset, uuid)
assets.append(
HTML.tag(
"li",
c=tags.link_to(
str(asset),
self.request.route_url(
f"{asset.asset_type}_assets.view", uuid=asset.uuid
),
),
)
)
return HTML.tag("ul", c=assets)
return super().serialize(field, cstruct, **kw)
class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Quantities field (on a Log record)
"""
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
readonly = kw.get("readonly", self.readonly)
if readonly:
quantities = []
for uuid in cstruct or []:
qty = session.get(model.Quantity, uuid)
quantities.append(
HTML.tag(
"li",
c=tags.link_to(
qty.render_as_text(self.config),
# TODO
self.request.route_url(
"quantities_standard.view", uuid=qty.uuid
),
),
)
)
return HTML.tag("ul", c=quantities)
return super().serialize(field, cstruct, **kw)
class OwnerRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Owners field (on an Asset or Log record)
"""
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
readonly = kw.get("readonly", self.readonly)
if readonly:
owners = [session.get(model.User, uuid) for uuid in cstruct or []]
owners = [user for user in owners if user]
owners.sort(key=lambda user: user.username)
links = []
for user in owners:
links.append(
HTML.tag(
"li",
c=tags.link_to(
user.username,
self.request.route_url("users.view", uuid=user.uuid),
),
)
)
return HTML.tag("ul", c=links)
return super().serialize(field, cstruct, **kw)
class AnimalTypeRefWidget(ObjectRefWidget):
"""
Custom widget which uses the ``<animal-type-picker>`` component.
"""
template = "animaltyperef"
def get_template_values(self, field, cstruct, kw):
""" """
values = super().get_template_values(field, cstruct, kw)
values["js_values"] = json.dumps(values["values"])
if self.request.has_perm("animal_types.create"):
values["can_create"] = True
return values

300
src/wuttafarm/web/grids.py Normal file
View file

@ -0,0 +1,300 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Custom grid stuff for use with farmOS / JSONAPI
"""
import datetime
from wuttaweb.grids.filters import GridFilter
class SimpleFilter(GridFilter):
default_verbs = ["equal", "not_equal"]
def __init__(self, request, key, path=None, **kwargs):
super().__init__(request, key, **kwargs)
self.path = path or key
def filter_equal(self, data, value):
if value := self.coerce_value(value):
data.add_filter(self.path, "=", value)
return data
def filter_not_equal(self, data, value):
if value := self.coerce_value(value):
data.add_filter(self.path, "<>", value)
return data
def filter_is_null(self, data, value):
data.add_filter(self.path, "IS NULL", None)
return data
def filter_is_not_null(self, data, value):
data.add_filter(self.path, "IS NOT NULL", None)
return data
class StringFilter(SimpleFilter):
default_verbs = ["contains", "equal", "not_equal"]
def filter_contains(self, data, value):
if value := self.coerce_value(value):
data.add_filter(self.path, "CONTAINS", value)
return data
class NullableStringFilter(StringFilter):
default_verbs = ["contains", "equal", "not_equal", "is_null", "is_not_null"]
class IntegerFilter(SimpleFilter):
default_verbs = [
"equal",
"not_equal",
"less_than",
"less_equal",
"greater_than",
"greater_equal",
]
def filter_less_than(self, data, value):
if value := self.coerce_value(value):
data.add_filter(self.path, "<", value)
return data
def filter_less_equal(self, data, value):
if value := self.coerce_value(value):
data.add_filter(self.path, "<=", value)
return data
def filter_greater_than(self, data, value):
if value := self.coerce_value(value):
data.add_filter(self.path, ">", value)
return data
def filter_greater_equal(self, data, value):
if value := self.coerce_value(value):
data.add_filter(self.path, ">=", value)
return data
class NullableIntegerFilter(IntegerFilter):
default_verbs = ["equal", "not_equal", "is_null", "is_not_null"]
class BooleanFilter(SimpleFilter):
default_verbs = ["is_true", "is_false"]
def filter_is_true(self, data, value):
data.add_filter(self.path, "=", 1)
return data
def filter_is_false(self, data, value):
data.add_filter(self.path, "=", 0)
return data
class NullableBooleanFilter(BooleanFilter):
default_verbs = ["is_true", "is_false", "is_null", "is_not_null"]
# TODO: this may not work, it's not used anywhere yet
class DateFilter(SimpleFilter):
data_type = "date"
default_verbs = [
"equal",
"not_equal",
"greater_than",
"greater_equal",
"less_than",
"less_equal",
# 'between',
]
default_verb_labels = {
"equal": "on",
"not_equal": "not on",
"greater_than": "after",
"greater_equal": "on or after",
"less_than": "before",
"less_equal": "on or before",
# "between": "between",
"is_null": "is null",
"is_not_null": "is not null",
"is_any": "is any",
}
def coerce_value(self, value):
if value:
if isinstance(value, datetime.date):
return value
try:
dt = datetime.datetime.strptime(value, "%Y-%m-%d")
except ValueError:
log.warning("invalid date value: %s", value)
else:
return dt.date()
return None
# TODO: this is not very complete yet, so far used only for animal birthdate
class DateTimeFilter(DateFilter):
default_verbs = ["equal", "is_null", "is_not_null"]
def coerce_value(self, value):
"""
Convert user input to a proper ``datetime.date`` object.
"""
if value:
if isinstance(value, datetime.date):
return value
try:
dt = datetime.datetime.strptime(value, "%Y-%m-%d")
except ValueError:
log.warning("invalid date value: %s", value)
else:
return dt.date()
return None
def filter_equal(self, data, value):
if value := self.coerce_value(value):
start = datetime.datetime.combine(value, datetime.time(0))
start = self.app.localtime(start, from_utc=False)
stop = datetime.datetime.combine(
value + datetime.timedelta(days=1), datetime.time(0)
)
stop = self.app.localtime(stop, from_utc=False)
data.add_filter(self.path, ">=", int(start.timestamp()))
data.add_filter(self.path, "<", int(stop.timestamp()))
return data
class SimpleSorter:
def __init__(self, key):
self.key = key
def __call__(self, data, sortdir):
data.add_sorter(self.key, sortdir)
return data
class ResourceData:
def __init__(
self,
config,
farmos_client,
content_type,
include=None,
normalizer=None,
):
self.config = config
self.farmos_client = farmos_client
self.entity, self.bundle = content_type.split("--")
self.filters = []
self.sorters = []
self.include = include
self.normalizer = normalizer
self._data = None
def __bool__(self):
return True
def __getitem__(self, subscript):
return self.get_data()[subscript]
def __len__(self):
return len(self._data)
def add_filter(self, path, operator, value):
self.filters.append((path, operator, value))
def add_sorter(self, path, sortdir):
self.sorters.append((path, sortdir))
def get_data(self):
if self._data is None:
params = {}
i = 0
for path, operator, value in self.filters:
i += 1
key = f"{i:03d}"
params[f"filter[{key}][condition][path]"] = path
params[f"filter[{key}][condition][operator]"] = operator
params[f"filter[{key}][condition][value]"] = value
sorters = []
for path, sortdir in self.sorters:
prefix = "-" if sortdir == "desc" else ""
sorters.append(f"{prefix}{path}")
if sorters:
params["sort"] = ",".join(sorters)
# nb. while the API allows for pagination, it does not
# tell me how many total records there are (IIUC). also
# if i ask for e.g. items 21-40 (page 2 @ 20/page) i am
# not guaranteed to get 20 items even if there are plenty
# in the DB, since Drupal may filter some out based on
# permissions. (granted that may not be an issue in
# practice, but can't rule it out.) so the punchline is,
# we fetch "all" (sic) data and send it to the frontend,
# and pagination happens there.
# TODO: if we ever try again, this sort of works...
# params["page[offset]"] = start
# params["page[limit]"] = stop - start
if self.include:
params["include"] = self.include
result = self.farmos_client.resource.get(
self.entity, self.bundle, params=params
)
data = result["data"]
included = {obj["id"]: obj for obj in result.get("included", [])}
if self.normalizer:
data = [self.normalizer(d, included) for d in data]
self._data = data
return self._data

View file

@ -32,12 +32,181 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"""
def make_menus(self, request, **kwargs):
return [
self.make_farmos_menu(request),
self.make_admin_menu(request, include_people=True),
]
enum = self.app.enum
mode = self.app.get_farmos_integration_mode()
def make_farmos_menu(self, request):
quick_menu = self.make_quick_menu(request)
admin_menu = self.make_admin_menu(request, include_people=True)
if mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER:
return [
quick_menu,
self.make_farmos_asset_menu(request),
self.make_farmos_log_menu(request),
self.make_farmos_other_menu(request),
admin_menu,
]
elif mode == enum.FARMOS_INTEGRATION_MODE_MIRROR:
return [
quick_menu,
self.make_asset_menu(request),
self.make_log_menu(request),
self.make_farmos_full_menu(request),
admin_menu,
]
else: # FARMOS_INTEGRATION_MODE_NONE
return [
quick_menu,
self.make_asset_menu(request),
self.make_log_menu(request),
admin_menu,
]
def make_quick_menu(self, request):
return {
"title": "Quick",
"type": "menu",
"items": [
{
"title": "Eggs",
"route": "quick.eggs",
"perm": "quick.eggs",
},
],
}
def make_asset_menu(self, request):
return {
"title": "Assets",
"type": "menu",
"items": [
{
"title": "All Assets",
"route": "assets",
"perm": "assets.list",
},
{
"title": "Animal",
"route": "animal_assets",
"perm": "animal_assets.list",
},
{
"title": "Group",
"route": "group_assets",
"perm": "group_assets.list",
},
{
"title": "Land",
"route": "land_assets",
"perm": "land_assets.list",
},
{
"title": "Plant",
"route": "plant_assets",
"perm": "plant_assets.list",
},
{
"title": "Structure",
"route": "structure_assets",
"perm": "structure_assets.list",
},
{"type": "sep"},
{
"title": "Animal Types",
"route": "animal_types",
"perm": "animal_types.list",
},
{
"title": "Land Types",
"route": "land_types",
"perm": "land_types.list",
},
{
"title": "Plant Types",
"route": "plant_types",
"perm": "plant_types.list",
},
{
"title": "Structure Types",
"route": "structure_types",
"perm": "structure_types.list",
},
{
"title": "Asset Types",
"route": "asset_types",
"perm": "asset_types.list",
},
],
}
def make_log_menu(self, request):
return {
"title": "Logs",
"type": "menu",
"items": [
{
"title": "All Logs",
"route": "log",
"perm": "log.list",
},
{
"title": "Activity",
"route": "logs_activity",
"perm": "logs_activity.list",
},
{
"title": "Harvest",
"route": "logs_harvest",
"perm": "logs_harvest.list",
},
{
"title": "Medical",
"route": "logs_medical",
"perm": "logs_medical.list",
},
{
"title": "Observation",
"route": "logs_observation",
"perm": "logs_observation.list",
},
{"type": "sep"},
{
"title": "All Quantities",
"route": "quantities",
"perm": "quantities.list",
},
{
"title": "Standard Quantities",
"route": "quantities_standard",
"perm": "quantities_standard.list",
},
{"type": "sep"},
{
"title": "Log Types",
"route": "log_types",
"perm": "log_types.list",
},
{
"title": "Measures",
"route": "measures",
"perm": "measures.list",
},
{
"title": "Quantity Types",
"route": "quantity_types",
"perm": "quantity_types.list",
},
{
"title": "Units",
"route": "units",
"perm": "units.list",
},
],
}
def make_farmos_full_menu(self, request):
config = request.wutta_config
app = config.get_app()
return {
@ -51,47 +220,73 @@ class WuttaFarmMenuHandler(base.MenuHandler):
},
{"type": "sep"},
{
"title": "Animals",
"route": "farmos_animals",
"perm": "farmos_animals.list",
"title": "Animal Assets",
"route": "farmos_animal_assets",
"perm": "farmos_animal_assets.list",
},
{
"title": "Groups",
"route": "farmos_groups",
"perm": "farmos_groups.list",
"title": "Group Assets",
"route": "farmos_group_assets",
"perm": "farmos_group_assets.list",
},
{
"title": "Structures",
"route": "farmos_structures",
"perm": "farmos_structures.list",
},
{
"title": "Land",
"title": "Land Assets",
"route": "farmos_land_assets",
"perm": "farmos_land_assets.list",
},
{
"title": "Plant Assets",
"route": "farmos_plant_assets",
"perm": "farmos_plant_assets.list",
},
{
"title": "Structure Assets",
"route": "farmos_structure_assets",
"perm": "farmos_structure_assets.list",
},
{"type": "sep"},
{
"title": "Activity Logs",
"route": "farmos_logs_activity",
"perm": "farmos_logs_activity.list",
},
{
"title": "Harvest Logs",
"route": "farmos_logs_harvest",
"perm": "farmos_logs_harvest.list",
},
{
"title": "Medical Logs",
"route": "farmos_logs_medical",
"perm": "farmos_logs_medical.list",
},
{
"title": "Observation Logs",
"route": "farmos_logs_observation",
"perm": "farmos_logs_observation.list",
},
{"type": "sep"},
{
"title": "Animal Types",
"route": "farmos_animal_types",
"perm": "farmos_animal_types.list",
},
{
"title": "Structure Types",
"route": "farmos_structure_types",
"perm": "farmos_structure_types.list",
},
{
"title": "Land Types",
"route": "farmos_land_types",
"perm": "farmos_land_types.list",
},
{
"title": "Plant Types",
"route": "farmos_plant_types",
"perm": "farmos_plant_types.list",
},
{
"title": "Structure Types",
"route": "farmos_structure_types",
"perm": "farmos_structure_types.list",
},
{"type": "sep"},
{
"title": "Asset Types",
"route": "farmos_asset_types",
@ -102,6 +297,155 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_log_types",
"perm": "farmos_log_types.list",
},
{
"title": "Quantity Types",
"route": "farmos_quantity_types",
"perm": "farmos_quantity_types.list",
},
{
"title": "Standard Quantities",
"route": "farmos_quantities_standard",
"perm": "farmos_quantities_standard.list",
},
{
"title": "Units",
"route": "farmos_units",
"perm": "farmos_units.list",
},
{"type": "sep"},
{
"title": "Users",
"route": "farmos_users",
"perm": "farmos_users.list",
},
],
}
def make_farmos_asset_menu(self, request):
config = request.wutta_config
app = config.get_app()
return {
"title": "Assets",
"type": "menu",
"items": [
{
"title": "Animal",
"route": "farmos_animal_assets",
"perm": "farmos_animal_assets.list",
},
{
"title": "Group",
"route": "farmos_group_assets",
"perm": "farmos_group_assets.list",
},
{
"title": "Land",
"route": "farmos_land_assets",
"perm": "farmos_land_assets.list",
},
{
"title": "Plant",
"route": "farmos_plant_assets",
"perm": "farmos_plant_assets.list",
},
{
"title": "Structure",
"route": "farmos_structure_assets",
"perm": "farmos_structure_assets.list",
},
{"type": "sep"},
{
"title": "Animal Types",
"route": "farmos_animal_types",
"perm": "farmos_animal_types.list",
},
{
"title": "Land Types",
"route": "farmos_land_types",
"perm": "farmos_land_types.list",
},
{
"title": "Plant Types",
"route": "farmos_plant_types",
"perm": "farmos_plant_types.list",
},
{
"title": "Structure Types",
"route": "farmos_structure_types",
"perm": "farmos_structure_types.list",
},
{"type": "sep"},
{
"title": "Asset Types",
"route": "farmos_asset_types",
"perm": "farmos_asset_types.list",
},
],
}
def make_farmos_log_menu(self, request):
config = request.wutta_config
app = config.get_app()
return {
"title": "Logs",
"type": "menu",
"items": [
{
"title": "Activity",
"route": "farmos_logs_activity",
"perm": "farmos_logs_activity.list",
},
{
"title": "Harvest",
"route": "farmos_logs_harvest",
"perm": "farmos_logs_harvest.list",
},
{
"title": "Medical",
"route": "farmos_logs_medical",
"perm": "farmos_logs_medical.list",
},
{
"title": "Observation",
"route": "farmos_logs_observation",
"perm": "farmos_logs_observation.list",
},
{"type": "sep"},
{
"title": "Log Types",
"route": "farmos_log_types",
"perm": "farmos_log_types.list",
},
{
"title": "Quantity Types",
"route": "farmos_quantity_types",
"perm": "farmos_quantity_types.list",
},
{
"title": "Standard Quantities",
"route": "farmos_quantities_standard",
"perm": "farmos_quantities_standard.list",
},
{
"title": "Units",
"route": "farmos_units",
"perm": "farmos_units.list",
},
],
}
def make_farmos_other_menu(self, request):
config = request.wutta_config
app = config.get_app()
return {
"title": "farmOS",
"type": "menu",
"items": [
{
"title": "Go to farmOS",
"url": app.get_farmos_url(),
"target": "_blank",
},
{"type": "sep"},
{
"title": "Users",

View file

@ -0,0 +1,82 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/appinfo/configure.mako" />
<%def name="form_content()">
${parent.form_content()}
<h3 class="block is-size-3">farmOS</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<b-field label="farmOS URL">
<b-input name="farmos.url.base"
v-model="simpleSettings['farmos.url.base']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field grouped>
<b-field label="OAuth2 Client ID">
<b-input name="farmos.oauth2.client_id"
v-model="simpleSettings['farmos.oauth2.client_id']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="OAuth2 Scope">
<b-input name="farmos.oauth2.scope"
v-model="simpleSettings['farmos.oauth2.scope']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
<b-field label="OAuth2 Redirect URI">
<wutta-copyable-text text="${url('farmos_oauth_callback')}" />
</b-field>
<b-field label="farmOS Integration Mode">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<b-select name="${app.appname}.farmos_integration_mode"
v-model="simpleSettings['${app.appname}.farmos_integration_mode']"
@input="settingsNeedSaved = true">
% for value, label in enum.FARMOS_INTEGRATION_MODE.items():
<option value="${value}">${label}</option>
% endfor
</b-select>
<${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}">
<b-icon pack="fas" icon="info-circle" type="is-warning" />
<template #content>
<p class="block">
<span class="has-text-weight-bold">RESTART IS REQUIRED</span>
if you change the integration mode.
</p>
</template>
</${b}-tooltip>
</div>
</b-field>
<b-checkbox name="${app.appname}.farmos_style_grid_links"
v-model="simpleSettings['${app.appname}.farmos_style_grid_links']"
native-value="true"
@input="settingsNeedSaved = true">
Use farmOS-style grid links
</b-checkbox>
<${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}">
<b-icon pack="fas" icon="info-circle" />
<template #content>
<p class="block">
If set, certain column values in a grid may link
to <span class="has-text-weight-bold">related</span>
records.
</p>
<p class="block">
If not set, column values will only link to view the
<span class="has-text-weight-bold">current</span> record.
</p>
</template>
</${b}-tooltip>
</div>
</%def>

View file

@ -0,0 +1,14 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="page_content()">
% if instance.archived:
<b-notification type="is-warning">
This asset is archived.
Archived assets should only be edited if they need corrections.
</b-notification>
% endif
${parent.page_content()}
</%def>

View file

@ -1,4 +1,5 @@
<%inherit file="wuttaweb:templates/base.mako" />
<%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" />
<%def name="index_title_controls()">
${parent.index_title_controls()}
@ -14,3 +15,8 @@
% endif
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
${make_wuttafarm_components()}
</%def>

View file

@ -12,5 +12,10 @@
</%def>
<%def name="footer()">
${parent.footer()}
<p class="has-text-centered">
powered by
${h.link_to("WuttaWeb", 'https://wuttaproject.org/', target='_blank')}
and
${h.link_to("farmOS", 'https://farmos.org/', target='_blank')}
</p>
</%def>

View file

@ -0,0 +1,13 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;
can_create can_create|False;"
tal:omit-tag="">
<animal-type-picker tal:attributes="name name;
v-model vmodel;
:animal-types js_values;
:can-create str(can_create).lower();" />
</div>

View file

@ -0,0 +1,13 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;
can_create can_create|False;"
tal:omit-tag="">
<plant-types-picker tal:attributes="name name;
v-model vmodel;
:plant-types js_values;
:can-create str(can_create).lower();" />
</div>

View file

@ -0,0 +1,45 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="tool_panels()">
${parent.tool_panels()}
${self.tool_panel_tools()}
</%def>
<%def name="tool_panel_tools()">
% if raw_json:
<wutta-tool-panel heading="Tools">
<b-button type="is-primary"
icon-pack="fas"
icon-left="code"
@click="viewJsonShowDialog = true">
See raw JSON data
</b-button>
</wutta-tool-panel>
<${b}-modal :width="1200"
% if request.use_oruga:
v-model:active="viewJsonShowDialog"
% else:
:active.sync="viewJsonShowDialog"
% endif
>
<div class="card">
<div class="card-content">
${rendered_json|n}
</div>
</div>
</${b}-modal>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if raw_json:
<script>
ThisPageData.viewJsonShowDialog = false
</script>
% endif
</%def>

View file

@ -0,0 +1,14 @@
<%inherit file="/form.mako" />
<%def name="title()">${index_title} &raquo; ${form_title}</%def>
<%def name="content_title()">${form_title}</%def>
<%def name="render_form_tag()">
<p class="block">
${help_text}
</p>
${parent.render_form_tag()}
</%def>

View file

@ -0,0 +1,324 @@
<%def name="make_wuttafarm_components()">
${self.make_animal_type_picker_component()}
${self.make_plant_types_picker_component()}
</%def>
<%def name="make_animal_type_picker_component()">
<script type="text/x-template" id="animal-type-picker-template">
<div>
<div style="display: flex; gap: 0.5rem;">
<b-select :name="name"
:value="internalValue"
@input="val => $emit('input', val)"
style="flex-grow: 1;">
<option v-for="atype in internalAnimalTypes"
:value="atype[0]">
{{ atype[1] }}
</option>
</b-select>
<b-button v-if="canCreate"
type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="createInit()">
New
</b-button>
</div>
<${b}-modal v-if="canCreate"
has-modal-card
% if request.use_oruga:
v-model:active="createShowDialog"
% else:
:active.sync="createShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">New Animal Type</p>
</header>
<section class="modal-card-body">
<b-field label="Name" horizontal>
<b-input v-model="createName"
ref="createName"
expanded
@keydown.native="createNameKeydown" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="createSave()"
:disabled="createSaving || !createName"
icon-pack="fas"
icon-left="save">
{{ createSaving ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="createShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
</div>
</script>
<script>
const AnimalTypePicker = {
template: '#animal-type-picker-template',
mixins: [WuttaRequestMixin],
props: {
name: String,
value: String,
animalTypes: Array,
canCreate: Boolean,
},
data() {
return {
internalAnimalTypes: this.animalTypes,
internalValue: this.value,
createShowDialog: false,
createName: null,
createSaving: false,
}
},
methods: {
createInit(name) {
this.createName = name || null
this.createShowDialog = true
this.$nextTick(() => {
this.$refs.createName.focus()
})
},
createNameKeydown(event) {
// nb. must prevent main form submit on ENTER
// (since ultimately this lives within an outer form)
// but also we can submit the modal pseudo-form
if (event.which == 13) {
event.preventDefault()
this.createSave()
}
},
createSave() {
this.createSaving = true
const url = "${url('animal_types.ajax_create')}"
const params = {name: this.createName}
this.wuttaPOST(url, params, response => {
this.internalAnimalTypes.push([response.data.uuid, response.data.name])
this.$nextTick(() => {
this.internalValue = response.data.uuid
this.createSaving = false
this.createShowDialog = false
})
}, response => {
this.createSaving = false
})
},
},
}
Vue.component('animal-type-picker', AnimalTypePicker)
<% request.register_component('animal-type-picker', 'AnimalTypePicker') %>
</script>
</%def>
<%def name="make_plant_types_picker_component()">
<script type="text/x-template" id="plant-types-picker-template">
<div>
<input type="hidden" :name="name" :value="value" />
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span>Add:</span>
<b-autocomplete v-model="addName"
ref="addName"
:data="addNameData"
field="name"
open-on-focus
keep-first
@select="addNameSelected"
clear-on-select
style="flex-grow: 1;">
<template #empty>No results found</template>
</b-autocomplete>
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="createInit()">
New
</b-button>
</div>
<${b}-table :data="plantTypeData">
<${b}-table-column field="name" v-slot="props">
<span>{{ props.row.name }}</span>
</${b}-table-column>
<${b}-table-column v-slot="props">
<a href="#"
class="has-text-danger"
@click.prevent="removePlantType(props.row)">
<i class="fas fa-trash" /> &nbsp; Remove
</a>
</${b}-table-column>
</${b}-table>
<${b}-modal v-if="canCreate"
has-modal-card
% if request.use_oruga:
v-model:active="createShowDialog"
% else:
:active.sync="createShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">New Plant Type</p>
</header>
<section class="modal-card-body">
<b-field label="Name" horizontal>
<b-input v-model="createName"
ref="createName"
expanded
@keydown.native="createNameKeydown" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="createSave()"
:disabled="createSaving || !createName"
icon-pack="fas"
icon-left="save">
{{ createSaving ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="createShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
</div>
</script>
<script>
const PlantTypesPicker = {
template: '#plant-types-picker-template',
mixins: [WuttaRequestMixin],
props: {
name: String,
value: Array,
plantTypes: Array,
canCreate: Boolean,
},
data() {
return {
internalPlantTypes: this.plantTypes.map((pt) => {
return {uuid: pt[0], name: pt[1]}
}),
addShowDialog: false,
addName: '',
createShowDialog: false,
createName: null,
createSaving: false,
}
},
computed: {
plantTypeData() {
const data = []
if (this.value) {
for (let ptype of this.internalPlantTypes) {
// ptype = {uuid: ptype[0], name: ptype[1]}
if (this.value.includes(ptype.uuid)) {
data.push(ptype)
}
}
}
return data
},
addNameData() {
if (!this.addName) {
return this.internalPlantTypes
}
return this.internalPlantTypes.filter((ptype) => {
return ptype.name.toLowerCase().indexOf(this.addName.toLowerCase()) >= 0
})
},
},
methods: {
addNameSelected(option) {
const value = Array.from(this.value || [])
if (!value.includes(option.uuid)) {
value.push(option.uuid)
this.$emit('input', value)
}
this.addName = null
},
createInit() {
this.createName = this.addName
this.createShowDialog = true
this.$nextTick(() => {
this.$refs.createName.focus()
})
},
createNameKeydown(event) {
// nb. must prevent main form submit on ENTER
// (since ultimately this lives within an outer form)
// but also we can submit the modal pseudo-form
if (event.which == 13) {
event.preventDefault()
this.createSave()
}
},
createSave() {
this.createSaving = true
const url = "${url('plant_types.ajax_create')}"
const params = {name: this.createName}
this.wuttaPOST(url, params, response => {
this.internalPlantTypes.push(response.data)
const value = Array.from(this.value || [])
value.push(response.data.uuid)
this.$emit('input', value)
this.addName = null
this.createSaving = false
this.createShowDialog = false
}, response => {
this.createSaving = false
})
},
removePlantType(ptype) {
let value = Array.from(this.value)
const i = value.indexOf(ptype.uuid)
value.splice(i, 1)
this.$emit('input', value)
},
},
}
Vue.component('plant-types-picker', PlantTypesPicker)
<% request.register_component('plant-types-picker', 'PlantTypesPicker') %>
</script>
</%def>

View file

@ -23,6 +23,28 @@
Misc. utilities for web app
"""
from pyramid import httpexceptions
from webhelpers2.html import HTML
def get_farmos_client_for_user(request):
token = request.session.get("farmos.oauth2.token")
if not token:
raise httpexceptions.HTTPForbidden()
# nb. must give a *copy* of the token to farmOS client, since it
# will mutate it in-place and we don't want that to happen for our
# original copy in the user session. (otherwise the auto-refresh
# will not work correctly for subsequent calls.)
token = dict(token)
def token_updater(token):
save_farmos_oauth2_token(request, token)
config = request.wutta_config
app = config.get_app()
return app.get_farmos_client(token=token, token_updater=token_updater)
def save_farmos_oauth2_token(request, token):
"""
@ -38,3 +60,22 @@ def save_farmos_oauth2_token(request, token):
# save token to user session
request.session["farmos.oauth2.token"] = token
def use_farmos_style_grid_links(config):
return config.get_bool(f"{config.appname}.farmos_style_grid_links", default=True)
def render_quantity_objects(quantities):
items = []
for quantity in quantities:
text = render_quantity_object(quantity)
items.append(HTML.tag("li", c=text))
return HTML.tag("ul", c=items)
def render_quantity_object(quantity):
measure = quantity["measure_name"]
value = quantity["value_decimal"]
unit = quantity["unit_name"]
return f"( {measure} ) {value} {unit}"

View file

@ -25,8 +25,14 @@ WuttaFarm Views
from wuttaweb.views import essential
from .master import WuttaFarmMasterView
def includeme(config):
wutta_config = config.registry.settings.get("wutta_config")
app = wutta_config.get_app()
enum = app.enum
mode = app.get_farmos_integration_mode()
# wuttaweb core
essential.defaults(
@ -34,8 +40,32 @@ def includeme(config):
**{
"wuttaweb.views.auth": "wuttafarm.web.views.auth",
"wuttaweb.views.common": "wuttafarm.web.views.common",
"wuttaweb.views.settings": "wuttafarm.web.views.settings",
"wuttaweb.views.users": "wuttafarm.web.views.users",
}
)
# native table views
if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER:
config.include("wuttafarm.web.views.units")
config.include("wuttafarm.web.views.quantities")
config.include("wuttafarm.web.views.asset_types")
config.include("wuttafarm.web.views.assets")
config.include("wuttafarm.web.views.land")
config.include("wuttafarm.web.views.structures")
config.include("wuttafarm.web.views.animals")
config.include("wuttafarm.web.views.groups")
config.include("wuttafarm.web.views.plants")
config.include("wuttafarm.web.views.logs")
config.include("wuttafarm.web.views.logs_activity")
config.include("wuttafarm.web.views.logs_harvest")
config.include("wuttafarm.web.views.logs_medical")
config.include("wuttafarm.web.views.logs_observation")
# quick form views
# (nb. these work with all integration modes)
config.include("wuttafarm.web.views.quick")
# views for farmOS
config.include("wuttafarm.web.views.farmos")
if mode != enum.FARMOS_INTEGRATION_MODE_NONE:
config.include("wuttafarm.web.views.farmos")

View file

@ -0,0 +1,312 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Animals
"""
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.util import get_form_data
from wuttafarm.db.model import AnimalType, AnimalAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.web.forms.schema import AnimalTypeRef
from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.util import get_farmos_client_for_user
class AnimalTypeView(AssetTypeMasterView):
"""
Master view for Animal Types
"""
model_class = AnimalType
route_prefix = "animal_types"
url_prefix = "/animal-types"
farmos_entity_type = "taxonomy_term"
farmos_bundle = "animal_type"
farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
grid_columns = [
"name",
"description",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"description",
"drupal_id",
"farmos_uuid",
]
has_rows = True
row_model_class = AnimalAsset
rows_viewable = True
row_grid_columns = [
"asset_name",
"sex",
"is_sterile",
"birthdate",
"archived",
]
rows_sort_defaults = "asset_name"
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# name
g.set_link("name")
def get_farmos_url(self, animal_type):
return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}")
def get_xref_buttons(self, animal_type):
buttons = super().get_xref_buttons(animal_type)
if animal_type.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_animal_types.view", uuid=animal_type.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def delete(self):
animal_type = self.get_instance()
if animal_type.animal_assets:
self.request.session.flash(
"Cannot delete animal type which is still referenced by animal assets.",
"warning",
)
url = self.get_action_url("view", animal_type)
return self.redirect(self.request.get_referrer(default=url))
return super().delete()
def get_row_grid_data(self, animal_type):
model = self.app.model
session = self.Session()
return (
session.query(model.AnimalAsset)
.join(model.Asset)
.filter(model.AnimalAsset.animal_type == animal_type)
)
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
model = self.app.model
enum = self.app.enum
# asset_name
g.set_link("asset_name")
g.set_sorter("asset_name", model.Asset.asset_name)
g.set_filter("asset_name", model.Asset.asset_name)
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
g.filters["sex"].verbs = ["equal", "not_equal"]
# archived
g.set_renderer("archived", "boolean")
g.set_sorter("archived", model.Asset.archived)
g.set_filter("archived", model.Asset.archived)
def get_row_action_url_view(self, animal, i):
return self.request.route_url("animal_assets.view", uuid=animal.uuid)
def ajax_create(self):
"""
AJAX view to create a new animal type.
"""
model = self.app.model
session = self.Session()
data = get_form_data(self.request)
name = data.get("name")
if not name:
return {"error": "Name is required"}
animal_type = model.AnimalType(name=name)
session.add(animal_type)
session.flush()
if self.app.is_farmos_mirror():
client = get_farmos_client_for_user(self.request)
self.app.auto_sync_to_farmos(animal_type, client=client)
return {
"uuid": animal_type.uuid.hex,
"name": animal_type.name,
"farmos_uuid": animal_type.farmos_uuid.hex,
"drupal_id": animal_type.drupal_id,
}
@classmethod
def defaults(cls, config):
""" """
cls._defaults(config)
cls._animal_type_defaults(config)
@classmethod
def _animal_type_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix()
# ajax_create
config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new")
config.add_view(
cls,
attr="ajax_create",
route_name=f"{route_prefix}.ajax_create",
permission=f"{permission_prefix}.create",
renderer="json",
)
class AnimalAssetView(AssetMasterView):
"""
Master view for Animal Assets
"""
model_class = AnimalAsset
route_prefix = "animal_assets"
url_prefix = "/assets/animal"
farmos_refurl_path = "/assets/animal"
farmos_bundle = "animal"
labels = {
"animal_type": "Species / Breed",
"is_sterile": "Sterile",
}
grid_columns = [
"thumbnail",
"drupal_id",
"asset_name",
"produces_eggs",
"animal_type",
"birthdate",
"is_sterile",
"sex",
"groups",
"owners",
"locations",
"archived",
]
form_fields = [
"asset_name",
"animal_type",
"birthdate",
"produces_eggs",
"sex",
"is_sterile",
"notes",
"asset_type",
"owners",
"locations",
"groups",
"archived",
"drupal_id",
"farmos_uuid",
"thumbnail_url",
"image_url",
"thumbnail",
"image",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
model = self.app.model
enum = self.app.enum
# animal_type
g.set_joiner("animal_type", lambda q: q.join(model.AnimalType))
g.set_sorter("animal_type", model.AnimalType.name)
g.set_filter("animal_type", model.AnimalType.name)
if self.farmos_style_grid_links:
g.set_renderer("animal_type", self.render_animal_type_for_grid)
else:
g.set_link("animal_type")
# birthdate
g.set_renderer("birthdate", "date")
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
g.filters["sex"].verbs = ["equal", "not_equal"]
def render_animal_type_for_grid(self, animal, field, value):
url = self.request.route_url("animal_types.view", uuid=animal.animal_type_uuid)
return tags.link_to(value, url)
def configure_form(self, form):
f = form
super().configure_form(f)
enum = self.app.enum
animal = f.model_instance
# animal_type
f.set_node("animal_type", AnimalTypeRef(self.request))
# sex
if not (self.creating or self.editing) and animal.sex is None:
pass # TODO: dict enum widget does not handle null values well
else:
f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX))
f.set_required("sex", False)
def defaults(config, **kwargs):
base = globals()
AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"])
AnimalTypeView.defaults(config)
AnimalAssetView = kwargs.get("AnimalAssetView", base["AnimalAssetView"])
AnimalAssetView.defaults(config)
def includeme(config):
defaults(config)

Some files were not shown because too many files have changed in this diff Show more