From 75fd110c795def430def0ff39a61eca5fcbf56f0 Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" Date: Sat, 1 Feb 2025 14:07:03 +0200 Subject: [PATCH 1/9] feat: Add detection locking functionality to action menu - Implemented lock/unlock feature for detections - Added conditional rendering for delete and lock actions - Updated Alpine.js state management to track detection lock status - Introduced new event listeners for lock/unlock state changes --- views/elements/actionMenu.html | 90 ++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/views/elements/actionMenu.html b/views/elements/actionMenu.html index c84e4af6..0dc8627b 100644 --- a/views/elements/actionMenu.html +++ b/views/elements/actionMenu.html @@ -3,6 +3,7 @@ x-data="{ open: false, isExcluded: {{if isSpeciesExcluded .CommonName}}true{{else}}false{{end}}, + isLocked: {{if .Locked}}true{{else}}false{{end}}, init() { document.body.addEventListener('species-excluded-{{.ID}}', () => { this.isExcluded = true; @@ -10,6 +11,12 @@ document.body.addEventListener('species-included-{{.ID}}', () => { this.isExcluded = false; }); + document.body.addEventListener('detection-locked-{{.ID}}', () => { + this.isLocked = true; + }); + document.body.addEventListener('detection-unlocked-{{.ID}}', () => { + this.isLocked = false; + }); } }" x-init="init()"> @@ -82,37 +89,78 @@
  • + }); + }; + modal.showModal();">
    - - - - Delete detection + + +
  • + From 232cf08f42685b8b973a47485e5ab22ba1fb83d9 Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" Date: Sat, 1 Feb 2025 14:07:16 +0200 Subject: [PATCH 2/9] feat: Add note locking functionality to datastore - Implemented lock management methods in datastore interfaces - Added NoteLock model to support note locking - Updated database migration to include NoteLock - Added methods for locking, unlocking, and checking note lock status - Modified note retrieval methods to preload and populate lock information - Added lock status check before note deletion - Excluded locked notes from clips qualifying for removal --- internal/datastore/interfaces.go | 109 +++++++++++++++++++++++++++++-- internal/datastore/manage.go | 2 +- internal/datastore/model.go | 14 +++- 3 files changed, 116 insertions(+), 9 deletions(-) diff --git a/internal/datastore/interfaces.go b/internal/datastore/interfaces.go index b5019078..cb054f46 100644 --- a/internal/datastore/interfaces.go +++ b/internal/datastore/interfaces.go @@ -47,6 +47,11 @@ type Interface interface { CountSpeciesDetections(species, date, hour string, duration int) (int64, error) CountSearchResults(query string) (int64, error) Transaction(fc func(tx *gorm.DB) error) error + // Lock management methods + LockNote(noteID string, description string, lockedBy string) error + UnlockNote(noteID string) error + GetNoteLock(noteID string) (*NoteLock, error) + IsNoteLocked(noteID string) (bool, error) } // DataStore implements StoreInterface using a GORM database. @@ -161,8 +166,8 @@ func (ds *DataStore) Get(id string) (Note, error) { } var note Note - // Retrieve the note by its ID with Review and Comments preloaded - if err := ds.DB.Preload("Review").Preload("Comments", func(db *gorm.DB) *gorm.DB { + // Retrieve the note by its ID with Review, Lock, and Comments preloaded + if err := ds.DB.Preload("Review").Preload("Lock").Preload("Comments", func(db *gorm.DB) *gorm.DB { return db.Order("created_at DESC") // Order comments by creation time, newest first }).First(¬e, noteID).Error; err != nil { return Note{}, fmt.Errorf("getting note with ID %d: %w", noteID, err) @@ -173,6 +178,9 @@ func (ds *DataStore) Get(id string) (Note, error) { note.Verified = note.Review.Verified } + // Populate virtual Locked field + note.Locked = note.Lock != nil + return note, nil } @@ -184,6 +192,15 @@ func (ds *DataStore) Delete(id string) error { return fmt.Errorf("converting ID to integer: %w", err) } + // Check if the note is locked + isLocked, err := ds.IsNoteLocked(id) + if err != nil { + return fmt.Errorf("checking note lock status: %w", err) + } + if isLocked { + return fmt.Errorf("cannot delete note: note is locked") + } + // Perform the deletion within a transaction return ds.DB.Transaction(func(tx *gorm.DB) error { // Delete the full results entry associated with the note @@ -281,7 +298,11 @@ func (ds *DataStore) GetClipsQualifyingForRemoval(minHours, minClips int) ([]Cli // Define a subquery to count the number of recordings per scientific name subquery := ds.DB.Model(&Note{}).Select("ID, scientific_name, ROW_NUMBER() OVER (PARTITION BY scientific_name) as num_recordings"). - Where("clip_name != ''") + Where("clip_name != ''"). + // Exclude notes that have a lock + Joins("LEFT JOIN note_locks ON notes.id = note_locks.note_id"). + Where("note_locks.id IS NULL") + if err := subquery.Error; err != nil { return nil, fmt.Errorf("error creating subquery: %w", err) } @@ -290,6 +311,9 @@ func (ds *DataStore) GetClipsQualifyingForRemoval(minHours, minClips int) ([]Cli err := ds.DB.Table("(?) AS n", ds.DB.Model(&Note{})). Select("n.ID, n.scientific_name, n.clip_name, sub.num_recordings"). Joins("INNER JOIN (?) AS sub ON n.ID = sub.ID", subquery). + // Exclude notes that have a lock + Joins("LEFT JOIN note_locks ON n.id = note_locks.note_id"). + Where("note_locks.id IS NULL"). Where("strftime('%s', 'now') - strftime('%s', begin_time) > ?", minHours*3600). // Convert hours to seconds for comparison Where("sub.num_recordings > ?", minClips). Scan(&results).Error @@ -347,7 +371,7 @@ func (ds *DataStore) GetHourlyOccurrences(date, commonName string, minConfidence func (ds *DataStore) SpeciesDetections(species, date, hour string, duration int, sortAscending bool, limit, offset int) ([]Note, error) { sortOrder := sortAscendingString(sortAscending) - query := ds.DB.Preload("Review").Preload("Comments", func(db *gorm.DB) *gorm.DB { + query := ds.DB.Preload("Review").Preload("Lock").Preload("Comments", func(db *gorm.DB) *gorm.DB { return db.Order("created_at DESC") // Order comments by creation time, newest first }).Where("common_name = ? AND date = ?", species, date) if hour != "" { @@ -362,11 +386,12 @@ func (ds *DataStore) SpeciesDetections(species, date, hour string, duration int, var detections []Note err := query.Find(&detections).Error - // Populate virtual Verified field + // Populate virtual fields for i := range detections { if detections[i].Review != nil { detections[i].Verified = detections[i].Review.Verified } + detections[i].Locked = detections[i].Lock != nil } return detections, err @@ -378,17 +403,18 @@ func (ds *DataStore) GetLastDetections(numDetections int) ([]Note, error) { now := time.Now() // Retrieve the most recent detections based on the ID in descending order - if result := ds.DB.Preload("Review").Preload("Comments", func(db *gorm.DB) *gorm.DB { + if result := ds.DB.Preload("Review").Preload("Lock").Preload("Comments", func(db *gorm.DB) *gorm.DB { return db.Order("created_at DESC") // Order comments by creation time, newest first }).Order("id DESC").Limit(numDetections).Find(¬es); result.Error != nil { return nil, fmt.Errorf("error getting last detections: %w", result.Error) } - // Populate virtual Verified field + // Populate virtual fields for i := range notes { if notes[i].Review != nil { notes[i].Verified = notes[i].Review.Verified } + notes[i].Locked = notes[i].Lock != nil } elapsed := time.Since(now) @@ -705,3 +731,72 @@ func (ds *DataStore) Transaction(fc func(tx *gorm.DB) error) error { } return ds.DB.Transaction(fc) } + +// LockNote creates or updates a lock for a note +func (ds *DataStore) LockNote(noteID, description, lockedBy string) error { + id, err := strconv.ParseUint(noteID, 10, 32) + if err != nil { + return fmt.Errorf("invalid note ID: %w", err) + } + + lock := &NoteLock{ + NoteID: uint(id), + LockedAt: time.Now(), + Description: description, + LockedBy: lockedBy, + } + + // Use upsert operation to either create or update the lock + result := ds.DB.Where("note_id = ?", id). + Assign(*lock). + FirstOrCreate(lock) + + if result.Error != nil { + return fmt.Errorf("failed to lock note: %w", result.Error) + } + + return nil +} + +// UnlockNote removes a lock from a note +func (ds *DataStore) UnlockNote(noteID string) error { + id, err := strconv.ParseUint(noteID, 10, 32) + if err != nil { + return fmt.Errorf("invalid note ID: %w", err) + } + + result := ds.DB.Where("note_id = ?", id).Delete(&NoteLock{}) + if result.Error != nil { + return fmt.Errorf("failed to unlock note: %w", result.Error) + } + + return nil +} + +// GetNoteLock retrieves the lock status for a note +func (ds *DataStore) GetNoteLock(noteID string) (*NoteLock, error) { + id, err := strconv.ParseUint(noteID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid note ID: %w", err) + } + + var lock NoteLock + err = ds.DB.Where("note_id = ?", id).First(&lock).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // Return nil if no lock exists + } + return nil, fmt.Errorf("error getting note lock: %w", err) + } + + return &lock, nil +} + +// IsNoteLocked checks if a note is locked +func (ds *DataStore) IsNoteLocked(noteID string) (bool, error) { + lock, err := ds.GetNoteLock(noteID) + if err != nil { + return false, err + } + return lock != nil, nil +} diff --git a/internal/datastore/manage.go b/internal/datastore/manage.go index c86c85d8..aeb08145 100644 --- a/internal/datastore/manage.go +++ b/internal/datastore/manage.go @@ -24,7 +24,7 @@ func createGormLogger() logger.Interface { // performAutoMigration automates database migrations with error handling. func performAutoMigration(db *gorm.DB, debug bool, dbType, connectionInfo string) error { - if err := db.AutoMigrate(&Note{}, &Results{}, &NoteReview{}, &NoteComment{}, &DailyEvents{}, &HourlyWeather{}); err != nil { + if err := db.AutoMigrate(&Note{}, &Results{}, &NoteReview{}, &NoteComment{}, &DailyEvents{}, &HourlyWeather{}, &NoteLock{}); err != nil { return fmt.Errorf("failed to auto-migrate %s database: %w", dbType, err) } diff --git a/internal/datastore/model.go b/internal/datastore/model.go index 8098eb9a..fab33a8e 100644 --- a/internal/datastore/model.go +++ b/internal/datastore/model.go @@ -26,9 +26,11 @@ type Note struct { Results []Results `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` Review *NoteReview `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` // One-to-one relationship with cascade delete Comments []NoteComment `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` // One-to-many relationship with cascade delete + Lock *NoteLock `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` // One-to-one relationship with cascade delete - // Virtual field to maintain compatibility with templates + // Virtual fields to maintain compatibility with templates Verified string `gorm:"-"` // This will be populated from Review.Verified + Locked bool `gorm:"-"` // This will be populated from Lock presence } // Result represents the identification result with a species name and its confidence level, linked to a Note. @@ -69,6 +71,16 @@ type NoteComment struct { UpdatedAt time.Time // When the comment was last updated } +// NoteLock represents the lock status of a Note +// GORM will automatically create table name as 'note_locks' +type NoteLock struct { + ID uint `gorm:"primaryKey"` + NoteID uint `gorm:"uniqueIndex;not null;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:NoteID;references:ID"` // Foreign key to associate with Note + LockedAt time.Time `gorm:"index;not null"` // When the note was locked + Description string `gorm:"type:text"` // Optional description of why the note was locked + LockedBy string `gorm:"type:varchar(255)"` // Who locked the note (can be username or system) +} + // DailyEvents represents the daily weather data that doesn't change throughout the day type DailyEvents struct { ID uint `gorm:"primaryKey"` From d6f7e78727ef457cf3546a5f0a26b8e3146a375e Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" Date: Sat, 1 Feb 2025 14:07:27 +0200 Subject: [PATCH 3/9] feat: Add lock detection option to review modal - Introduced lock detection checkbox in review modal - Added dynamic display of lock section based on detection verification - Implemented client-side toggle for lock detection visibility - Provided explanatory text for detection locking functionality --- views/elements/reviewModal.html | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/views/elements/reviewModal.html b/views/elements/reviewModal.html index f661a33d..efa6d7c8 100644 --- a/views/elements/reviewModal.html +++ b/views/elements/reviewModal.html @@ -59,15 +59,26 @@

    Review Detection: {{.CommonName | js}}

    + +
    + +
    + Locking this detection will prevent it from being deleted during regular cleanup. +
    +
    +
    -
    -
    +
    @@ -80,7 +102,7 @@

    Review Detection: {{.CommonName | js}}

    -
    +