activity: basic query support

only supports queries of fields actually in Activity. The web UI only
directly queries two of these, ID and Time (mistakenly referneced as date in
the web ui previously). Later commits will come up with creative ways of
dealing with all the other query types.
This commit is contained in:
Harvey Tindall
2025-05-23 16:37:42 +01:00
parent 0a7093a3b4
commit 6fff8a887e
5 changed files with 185 additions and 55 deletions

137
activitysort.go Normal file
View File

@@ -0,0 +1,137 @@
package main
import (
"fmt"
"strings"
"github.com/timshannon/badgerhold/v4"
)
const (
ACTIVITY_DEFAULT_SORT_FIELD = "Time"
// This will be default anyway, as the default value of a bool field is false.
// ACTIVITY_DEFAULT_SORT_ASCENDING = false
)
func activityDTONameToField(field string) string {
// Only "ID" and "Time" of these are actually searched by the UI.
// We support the rest though for other consumers of the API.
switch field {
case "id":
return "ID"
case "type":
return "Type"
case "user_id":
return "UserID"
case "username":
return "Username"
case "source_type":
return "SourceType"
case "source":
return "Source"
case "source_username":
return "SourceUsername"
case "invite_code":
return "InviteCode"
case "value":
return "Value"
case "time":
return "Time"
case "ip":
return "IP"
}
return "unknown"
// Only these query types actually search the ActivityDTO data.
}
func activityTypeGetterNameToType(getter string) ActivityType {
switch getter {
case "accountCreation":
return ActivityCreation
case "accountDeletion":
return ActivityDeletion
case "accountDisabled":
return ActivityDisabled
case "accountEnabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "passwordChange":
return ActivityChangePassword
case "passwordReset":
return ActivityResetPassword
case "inviteCreated":
return ActivityCreateInvite
case "inviteDeleted":
return ActivityDeleteInvite
}
return ActivityUnknown
}
// AsDBQuery returns a mutated "query" filtering for the conditions in "q".
func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
if query == nil {
query = &badgerhold.Query{}
}
// Special case for activity type:
// In the app, there isn't an "activity:<fieldname>" query, but rather "<~fieldname>:true/false" queries.
// For other API consumers, we also handle the former later.
activityType := activityTypeGetterNameToType(q.Field)
if activityType != ActivityUnknown {
criterion := query.And("Type")
if q.Operator != EqualOperator {
panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator))
}
if q.Value.(bool) == true {
query = criterion.Eq(activityType)
} else {
query = criterion.Ne(activityType)
}
return query
}
fieldName := activityDTONameToField(q.Field)
if fieldName == "unknown" {
panic("FIXME: Support all the weird queries of the web UI!")
}
criterion := query.And(fieldName)
// FIXME: Deal with dates like we do in usercache.go
switch q.Operator {
case LesserOperator:
query = criterion.Lt(q.Value)
case EqualOperator:
query = criterion.Eq(q.Value)
case GreaterOperator:
query = criterion.Gt(q.Value)
}
return query
}
// ActivityMatchesSearchAsDBBaseQuery returns a base query (which you should then apply other mutations to) matching the search "term" to Activities by searching all fields. Does not search the generated title like the web app.
func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
subQuery := &badgerhold.Query{}
// I don't believe you can just do Where("*"), so instead run for each field.
for _, fieldName := range []string{"ID", "Type", "UserID", "Username", "SourceType", "Source", "SourceUsername", "InviteCode", "Value", "IP"} {
criterion := badgerhold.Where(fieldName)
// No case-insentive Contains method, so we use MatchFunc instead
subQuery = subQuery.Or(criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
field := ra.Field()
// _, ok := field.(string)
// if !ok {
// return false, fmt.Errorf("field not string: %s", fieldName)
// }
lower := strings.ToLower(field.(string))
for _, term := range terms {
if strings.Contains(lower, term) {
return true, nil
}
}
return false, nil
}))
}
return subQuery
}

View File

@@ -6,32 +6,6 @@ import (
"github.com/timshannon/badgerhold/v4"
)
func stringToActivityType(v string) ActivityType {
switch v {
case "creation":
return ActivityCreation
case "deletion":
return ActivityDeletion
case "disabled":
return ActivityDisabled
case "enabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "changePassword":
return ActivityChangePassword
case "resetPassword":
return ActivityResetPassword
case "createInvite":
return ActivityCreateInvite
case "deleteInvite":
return ActivityDeleteInvite
}
return ActivityUnknown
}
func activityTypeToString(v ActivityType) string {
switch v {
case ActivityCreation:
@@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string {
return "unknown"
}
func stringToActivityType(v string) ActivityType {
switch v {
case "creation":
return ActivityCreation
case "deletion":
return ActivityDeletion
case "disabled":
return ActivityDisabled
case "enabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "changePassword":
return ActivityChangePassword
case "resetPassword":
return ActivityResetPassword
case "createInvite":
return ActivityCreateInvite
case "deleteInvite":
return ActivityDeleteInvite
}
return ActivityUnknown
}
func stringToActivitySource(v string) ActivitySource {
switch v {
case "user":
@@ -88,33 +88,36 @@ func activitySourceToString(v ActivitySource) string {
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
// @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivities(gc *gin.Context) {
req := GetActivitiesDTO{}
req := ServerSearchReqDTO{}
gc.BindJSON(&req)
query := &badgerhold.Query{}
activityTypes := make([]any, len(req.Type))
for i, v := range req.Type {
activityTypes[i] = stringToActivityType(v)
}
if len(activityTypes) != 0 {
query = badgerhold.Where("Type").In(activityTypes...)
if req.SortByField == "" {
req.SortByField = USER_DEFAULT_SORT_FIELD
} else {
req.SortByField = activityDTONameToField(req.SortByField)
}
var query *badgerhold.Query
if len(req.SearchTerms) != 0 {
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
} else {
query = &badgerhold.Query{}
}
for _, q := range req.Queries {
query = q.AsDBQuery(query)
}
query = query.SortBy(req.SortByField)
if !req.Ascending {
query = query.Reverse()
}
query = query.SortBy("Time")
if req.Limit == 0 {
req.Limit = 10
}
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
var results []Activity

View File

@@ -936,14 +936,14 @@ func (app *appContext) GetUsers(gc *gin.Context) {
// @Summary Get a paginated, searchable list of Jellyfin users.
// @Produce json
// @Param getUsersReqDTO body getUsersReqDTO true "search / pagination parameters"
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters"
// @Success 200 {object} getUsersDTO
// @Failure 500 {object} stringResponse
// @Router /users [post]
// @Security Bearer
// @tags Users
func (app *appContext) SearchUsers(gc *gin.Context) {
req := getUsersReqDTO{}
req := ServerSearchReqDTO{}
gc.BindJSON(&req)
if req.SortByField == "" {
req.SortByField = USER_DEFAULT_SORT_FIELD

View File

@@ -175,10 +175,6 @@ type PaginatedReqDTO struct {
Ascending bool `json:"ascending"`
}
type getUsersReqDTO struct {
ServerSearchReqDTO
}
type getUsersDTO struct {
UserList []*respUser `json:"users"`
LastPage bool `json:"last_page"`
@@ -445,12 +441,6 @@ type ActivityDTO struct {
IP string `json:"ip"`
}
type GetActivitiesDTO struct {
// "SortByField" ignores, it's always time.
PaginatedReqDTO
Type []string `json:"type"` // Types of activity to get. Leave blank for all.
}
type GetActivitiesRespDTO struct {
PaginatedDTO
Activities []ActivityDTO `json:"activities"`

View File

@@ -74,9 +74,9 @@ const queries = (): { [field: string]: QueryType } => { return {
string: true,
date: false
},
"date": {
"time": {
name: window.lang.strings("date"),
getter: "date",
getter: "time",
bool: false,
string: false,
date: true