mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Mirror the lens make/model editing surface for cameras: entity UpdateMakeModel/SaveForm, form validation, query, search, the GET/PUT /api/v1/cameras endpoints, and the cameras CLI command, plus a cameras ACL resource and scope. Also tidy the lens surface for parity: self-validating SaveForm, empty make/model guard, X-Count search header, service-role grant, the empty-id/slug docs, and order cameras before lenses everywhere.
This commit is contained in:
parent
7c6546ce19
commit
bc327016ad
43 changed files with 1354 additions and 22 deletions
81
internal/api/cameras.go
Normal file
81
internal/api/cameras.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/entity/query"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
)
|
||||
|
||||
// UpdateCamera updates camera make and model properties.
|
||||
//
|
||||
// PUT /api/v1/cameras/:id
|
||||
//
|
||||
// @Summary updates camera name
|
||||
// @Id UpdateCamera
|
||||
// @Tags Cameras
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} entity.Camera
|
||||
// @Failure 401,403,404,429 {object} i18n.Response
|
||||
// @Param id path string true "Camera ID"
|
||||
// @Param camera body form.Camera true "Properties to be updated, only Make and Model supported"
|
||||
// @Router /api/v1/cameras/{id} [put]
|
||||
func UpdateCamera(router *gin.RouterGroup) {
|
||||
router.PUT("/cameras/:id", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourceCameras, acl.ActionUpdate)
|
||||
|
||||
if s.Abort(c) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find camera by ID. A non-numeric id parses to 0 and is rejected as not found below,
|
||||
// so the parse error is intentionally ignored.
|
||||
cameraId, _ := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32)
|
||||
m := query.FindCameraByID(uint(cameraId))
|
||||
|
||||
if m == nil {
|
||||
Abort(c, http.StatusNotFound, i18n.ErrCameraNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new camera form.
|
||||
frm, frmErr := form.NewCamera(m)
|
||||
|
||||
if frmErr != nil {
|
||||
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Set form values from request.
|
||||
LimitRequestBodyBytes(c, MaxMutationRequestBytes)
|
||||
|
||||
if frmErr = c.BindJSON(frm); frmErr != nil {
|
||||
if IsRequestBodyTooLarge(frmErr) {
|
||||
AbortRequestTooLarge(c, i18n.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
AbortBadRequest(c, frmErr)
|
||||
return
|
||||
} else if frmErr = frm.Validate(); frmErr != nil {
|
||||
AbortInvalidName(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Save camera and return new model values if successful.
|
||||
if err := m.SaveForm(frm); err != nil {
|
||||
log.Errorf("camera: %s", clean.Error(err))
|
||||
AbortSaveFailed(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, m)
|
||||
})
|
||||
}
|
||||
63
internal/api/cameras_search.go
Normal file
63
internal/api/cameras_search.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/entity/search"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// SearchCameras finds and returns cameras as JSON.
|
||||
//
|
||||
// @Summary finds and returns cameras as JSON
|
||||
// @Id SearchCameras
|
||||
// @Tags Cameras
|
||||
// @Produce json
|
||||
// @Success 200 {array} search.Camera
|
||||
// @Header 200 {number} X-Count "The actual number of cameras returned"
|
||||
// @Header 200 {number} X-Limit "The limit of the number of cameras to be returned"
|
||||
// @Header 200 {number} X-Offset "The offset that was used"
|
||||
// @Failure 401,429,403,400 {object} i18n.Response
|
||||
// @Param count query int true "maximum number of results" minimum(1) maximum(100000)
|
||||
// @Param offset query int false "search result offset" minimum(0) maximum(100000)
|
||||
// @Param nomake query bool false "show where make is blank"
|
||||
// @Param q query string false "search query"
|
||||
// @Router /api/v1/cameras [get]
|
||||
func SearchCameras(router *gin.RouterGroup) {
|
||||
router.GET("/cameras", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourceCameras, acl.ActionSearch)
|
||||
|
||||
if s.Abort(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var frm form.SearchCameras
|
||||
|
||||
err := c.MustBindWith(&frm, binding.Form)
|
||||
|
||||
if err != nil {
|
||||
AbortBadRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Search matching cameras.
|
||||
result, err := search.Cameras(frm)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
AddCountHeader(c, len(result))
|
||||
AddLimitHeader(c, frm.Count)
|
||||
AddOffsetHeader(c, frm.Offset)
|
||||
AddTokenHeaders(c, s)
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
33
internal/api/cameras_search_test.go
Normal file
33
internal/api/cameras_search_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSearchCameras(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
SearchCameras(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/cameras?count=15")
|
||||
count := gjson.Get(r.Body.String(), "#")
|
||||
assert.Greater(t, count.Int(), int64(0))
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
result := r.Result()
|
||||
xCount, err := strconv.Atoi(result.Header.Get("X-Count"))
|
||||
assert.NoError(t, err, "strconv for X-Count failed")
|
||||
// X-Count must equal the number of records in the body.
|
||||
assert.Equal(t, int(count.Int()), xCount)
|
||||
})
|
||||
t.Run("InvalidRequest", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
SearchCameras(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/cameras?xxx=15")
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
}
|
||||
55
internal/api/cameras_test.go
Normal file
55
internal/api/cameras_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
)
|
||||
|
||||
func TestUpdateCamera(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
defer func() {
|
||||
entity.FlushCameraCache()
|
||||
assert.NoError(t, entity.UnscopedDb().Save(entity.CameraFixtures.Pointer("canon-eos-7d")).Error)
|
||||
}()
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateCamera(router)
|
||||
r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": "Pentax", "Model": "K-1"}`)
|
||||
val := gjson.Get(r.Body.String(), "Name")
|
||||
assert.Equal(t, "PENTAX K-1", val.String())
|
||||
val2 := gjson.Get(r.Body.String(), "Model")
|
||||
assert.Equal(t, "K-1", val2.String())
|
||||
val3 := gjson.Get(r.Body.String(), "Make")
|
||||
assert.Equal(t, "PENTAX", val3.String())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
t.Run("InvalidRequest", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateCamera(router)
|
||||
r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": 123, "Model": ""}`)
|
||||
val := gjson.Get(r.Body.String(), "error")
|
||||
assert.Equal(t, "Unable to do that", val.String())
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
t.Run("BadModel", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateCamera(router)
|
||||
r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": "123", "Model": ""}`)
|
||||
val := gjson.Get(r.Body.String(), "error")
|
||||
assert.Equal(t, "Invalid name", val.String())
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateCamera(router)
|
||||
r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/199000002", `{"Make": "Pentax", "Model": "K-1"}`)
|
||||
val := gjson.Get(r.Body.String(), "error")
|
||||
assert.Equal(t, "Camera not found", val.String())
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
}
|
||||
|
|
@ -35,8 +35,9 @@ func UpdateLens(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
// Find lens by ID.
|
||||
lensId, err := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32)
|
||||
// Find lens by ID. A non-numeric id parses to 0 and is rejected as not found below,
|
||||
// so the parse error is intentionally ignored.
|
||||
lensId, _ := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32)
|
||||
m := query.FindLensByID(uint(lensId))
|
||||
|
||||
if m == nil {
|
||||
|
|
@ -69,7 +70,7 @@ func UpdateLens(router *gin.RouterGroup) {
|
|||
}
|
||||
|
||||
// Save lens and return new model values if successful.
|
||||
if err = m.SaveForm(frm); err != nil {
|
||||
if err := m.SaveForm(frm); err != nil {
|
||||
log.Errorf("lens: %s", clean.Error(err))
|
||||
AbortSaveFailed(c)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import (
|
|||
// @Tags Lenses
|
||||
// @Produce json
|
||||
// @Success 200 {array} search.Lens
|
||||
// @Header 200 {number} X-Count "The actual number of lenses returned"
|
||||
// @Header 200 {number} X-Limit "The limit of the number of lenses to be returned"
|
||||
// @Header 200 {number} X-Offset "The offset that was used"
|
||||
// @Failure 401,429,403,400 {object} i18n.Response
|
||||
// @Param count query int true "maximum number of results" minimum(1) maximum(100000)
|
||||
// @Param offset query int false "search result offset" minimum(0) maximum(100000)
|
||||
|
|
@ -50,7 +53,7 @@ func SearchLenses(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
// TODO c.Header("X-Count", strconv.Itoa(count))
|
||||
AddCountHeader(c, len(result))
|
||||
AddLimitHeader(c, frm.Count)
|
||||
AddOffsetHeader(c, frm.Offset)
|
||||
AddTokenHeaders(c, s)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
|
@ -15,8 +16,13 @@ func TestSearchLenses(t *testing.T) {
|
|||
SearchLenses(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/lenses?count=15")
|
||||
count := gjson.Get(r.Body.String(), "#")
|
||||
assert.LessOrEqual(t, int64(3), count.Int())
|
||||
assert.Greater(t, count.Int(), int64(0))
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
result := r.Result()
|
||||
xCount, err := strconv.Atoi(result.Header.Get("X-Count"))
|
||||
assert.NoError(t, err, "strconv for X-Count failed")
|
||||
// X-Count must equal the number of records in the body.
|
||||
assert.Equal(t, int(count.Int()), xCount)
|
||||
})
|
||||
t.Run("InvalidRequest", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
|
|
|
|||
|
|
@ -920,6 +920,9 @@
|
|||
"calendar": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"cameras": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"delete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
@ -2675,6 +2678,17 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"form.Camera": {
|
||||
"properties": {
|
||||
"Make": {
|
||||
"type": "string"
|
||||
},
|
||||
"Model": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"form.ChangePassword": {
|
||||
"properties": {
|
||||
"new": {
|
||||
|
|
@ -3955,6 +3969,44 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"search.Camera": {
|
||||
"properties": {
|
||||
"CreatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"DeletedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"Description": {
|
||||
"type": "string"
|
||||
},
|
||||
"ID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"Make": {
|
||||
"type": "string"
|
||||
},
|
||||
"Model": {
|
||||
"type": "string"
|
||||
},
|
||||
"Name": {
|
||||
"type": "string"
|
||||
},
|
||||
"Notes": {
|
||||
"type": "string"
|
||||
},
|
||||
"Slug": {
|
||||
"type": "string"
|
||||
},
|
||||
"Type": {
|
||||
"type": "string"
|
||||
},
|
||||
"UpdatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"search.Face": {
|
||||
"properties": {
|
||||
"CollisionRadius": {
|
||||
|
|
@ -6766,6 +6818,163 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/cameras": {
|
||||
"get": {
|
||||
"operationId": "SearchCameras",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "maximum number of results",
|
||||
"in": "query",
|
||||
"maximum": 100000,
|
||||
"minimum": 1,
|
||||
"name": "count",
|
||||
"required": true,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"description": "search result offset",
|
||||
"in": "query",
|
||||
"maximum": 100000,
|
||||
"minimum": 0,
|
||||
"name": "offset",
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"description": "show where make is blank",
|
||||
"in": "query",
|
||||
"name": "nomake",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "search query",
|
||||
"in": "query",
|
||||
"name": "q",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"X-Count": {
|
||||
"description": "The actual number of cameras returned",
|
||||
"type": "number"
|
||||
},
|
||||
"X-Limit": {
|
||||
"description": "The limit of the number of cameras to be returned",
|
||||
"type": "number"
|
||||
},
|
||||
"X-Offset": {
|
||||
"description": "The offset that was used",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/search.Camera"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "finds and returns cameras as JSON",
|
||||
"tags": [
|
||||
"Cameras"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/cameras/{id}": {
|
||||
"put": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"operationId": "UpdateCamera",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Camera ID",
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Properties to be updated, only Make and Model supported",
|
||||
"in": "body",
|
||||
"name": "camera",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/form.Camera"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/entity.Camera"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "updates camera name",
|
||||
"tags": [
|
||||
"Cameras"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/cluster": {
|
||||
"get": {
|
||||
"operationId": "ClusterSummary",
|
||||
|
|
@ -8736,6 +8945,20 @@
|
|||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"X-Count": {
|
||||
"description": "The actual number of lenses returned",
|
||||
"type": "number"
|
||||
},
|
||||
"X-Limit": {
|
||||
"description": "The limit of the number of lenses to be returned",
|
||||
"type": "number"
|
||||
},
|
||||
"X-Offset": {
|
||||
"description": "The offset that was used",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/search.Lens"
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const (
|
|||
ResourcePeople Resource = "people"
|
||||
ResourcePlaces Resource = "places"
|
||||
ResourceLabels Resource = "labels"
|
||||
ResourceCameras Resource = "cameras"
|
||||
ResourceLenses Resource = "lenses"
|
||||
ResourceConfig Resource = "config"
|
||||
ResourceSettings Resource = "settings"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ var ResourceNames = []Resource{
|
|||
ResourcePeople,
|
||||
ResourcePlaces,
|
||||
ResourceLabels,
|
||||
ResourceCameras,
|
||||
ResourceLenses,
|
||||
ResourceConfig,
|
||||
ResourceSettings,
|
||||
|
|
|
|||
|
|
@ -57,9 +57,15 @@ var Rules = ACL{
|
|||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceCameras: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleService: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceLenses: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleService: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceConfig: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ var ScopeDescriptions = map[string]string{
|
|||
ResourcePeople.String(): "Manage people records and face assignments.",
|
||||
ResourcePlaces.String(): "Access maps, locations, and place clusters.",
|
||||
ResourceLabels.String(): "Manage subject labels and keywords.",
|
||||
ResourceLenses.String(): "Manage camera lense make and model values.",
|
||||
ResourceCameras.String(): "Manage camera make and model values.",
|
||||
ResourceLenses.String(): "Manage lens make and model values.",
|
||||
ResourceConfig.String(): "Read configuration reports and summaries.",
|
||||
ResourceSettings.String(): "Read and update application settings.",
|
||||
ResourcePasscode.String(): "Manage app passcodes and guest access codes.",
|
||||
|
|
|
|||
143
internal/commands/cameras.go
Normal file
143
internal/commands/cameras.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity/query"
|
||||
"github.com/photoprism/photoprism/internal/entity/search"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
|
||||
// CamerasCommand registers the "cameras" CLI command.
|
||||
var CamerasCommand = &cli.Command{
|
||||
Name: "cameras",
|
||||
Usage: "Camera management subcommands",
|
||||
Subcommands: []*cli.Command{
|
||||
CamerasListCommand,
|
||||
CamerasUpdateCommand,
|
||||
},
|
||||
}
|
||||
|
||||
// CamerasListCommand registers the list sub command.
|
||||
var CamerasListCommand = &cli.Command{
|
||||
Name: "ls",
|
||||
Usage: "Lists discovered cameras",
|
||||
ArgsUsage: "[query]",
|
||||
Flags: append(report.CliFlags, CountFlag, OffsetFlag, NoMakeFlag),
|
||||
Action: camerasListAction,
|
||||
}
|
||||
|
||||
// CamerasUpdateCommand registers the update sub command.
|
||||
var CamerasUpdateCommand = &cli.Command{
|
||||
Name: "update",
|
||||
Usage: "Updates a specific camera Make and Model",
|
||||
Flags: []cli.Flag{
|
||||
&cli.UintFlag{Name: "id", Usage: "camera id", Required: true},
|
||||
&cli.StringFlag{Name: "make", Usage: "the make of the camera", Required: true},
|
||||
&cli.StringFlag{Name: "model", Usage: "the model of the camera", Required: true},
|
||||
},
|
||||
Action: camerasUpdateAction,
|
||||
}
|
||||
|
||||
// camerasListAction searches the database for cameras.
|
||||
func camerasListAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
|
||||
filter := strings.TrimSpace(strings.Join(ctx.Args().Slice(), " "))
|
||||
// Pagination identical to API defaults.
|
||||
count := int(ctx.Uint("count")) //nolint:gosec // CLI flag bounded by validation
|
||||
if count <= 0 || count > 1000 {
|
||||
count = 100
|
||||
}
|
||||
offset := max(ctx.Int("offset"), 0)
|
||||
|
||||
frm := form.SearchCameras{
|
||||
Query: filter,
|
||||
NoMake: ctx.Bool("nomake"),
|
||||
Count: count,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
results, err := search.Cameras(frm)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
format := report.CliFormat(ctx)
|
||||
|
||||
cols := []string{"ID", "Camera Slug", "Camera Name", "Camera Make", "Camera Model", "Updated At"}
|
||||
rows := make([][]string, 0, len(results))
|
||||
|
||||
for _, found := range results {
|
||||
v := []string{strconv.FormatUint(uint64(found.ID), 10), found.CameraSlug, found.CameraName, found.CameraMake, found.CameraModel, found.UpdatedAt.Format("2006-01-02 15:04:05")}
|
||||
rows = append(rows, v)
|
||||
}
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(result)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// camerasUpdateAction updates the make and model of a specific camera.
|
||||
func camerasUpdateAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
|
||||
cameraId := ctx.Uint("id")
|
||||
cameraMake := ctx.String("make")
|
||||
cameraModel := ctx.String("model")
|
||||
|
||||
camera := query.FindCameraByID(cameraId)
|
||||
if camera == nil {
|
||||
return cli.Exit("camera not found", 1)
|
||||
}
|
||||
if err := camera.UpdateMakeModel(cameraMake, cameraModel); err != nil {
|
||||
return cli.Exit(err, 1)
|
||||
}
|
||||
|
||||
frm := form.SearchCameras{
|
||||
ID: strconv.FormatUint(uint64(camera.ID), 10),
|
||||
Count: 10,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
results, err := search.Cameras(frm)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
format := report.CliFormat(ctx)
|
||||
|
||||
cols := []string{"ID", "Camera Slug", "Camera Name", "Camera Make", "Camera Model", "Updated At"}
|
||||
rows := make([][]string, 0, len(results))
|
||||
|
||||
for _, found := range results {
|
||||
v := []string{strconv.FormatUint(uint64(found.ID), 10), found.CameraSlug, found.CameraName, found.CameraMake, found.CameraModel, found.UpdatedAt.Format("2006-01-02 15:04:05")}
|
||||
rows = append(rows, v)
|
||||
}
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(result)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
86
internal/commands/cameras_test.go
Normal file
86
internal/commands/cameras_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
)
|
||||
|
||||
func TestCamerasCommand(t *testing.T) {
|
||||
t.Run("ListNoOptions", func(t *testing.T) {
|
||||
// Run command with test context.
|
||||
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check command output for plausibility.
|
||||
for _, expect := range entity.CameraFixtures {
|
||||
assert.Contains(t, output, strconv.FormatUint(uint64(expect.ID), 10))
|
||||
assert.Contains(t, output, expect.CameraSlug)
|
||||
assert.Contains(t, output, expect.CameraName)
|
||||
}
|
||||
})
|
||||
t.Run("ListWithCount", func(t *testing.T) {
|
||||
// Run command with test context.
|
||||
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls", "--count=1", "--offset=0"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Canon EOS 7D sorts last by make/model/slug, so it must not appear in the first row.
|
||||
assert.NotEmpty(t, output)
|
||||
assert.NotContains(t, output, "1000002")
|
||||
})
|
||||
t.Run("ListWithNoMake", func(t *testing.T) {
|
||||
// Run command with test context.
|
||||
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls", "--nomake"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Only the unknown camera has a blank make.
|
||||
assert.Contains(t, output, "zz")
|
||||
assert.Contains(t, output, "Unknown")
|
||||
assert.NotContains(t, output, "1000002")
|
||||
assert.NotContains(t, output, "Canon EOS 7D")
|
||||
})
|
||||
t.Run("UpdateWithNoModel", func(t *testing.T) {
|
||||
// Run command with test context.
|
||||
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=Nikon"})
|
||||
assert.Error(t, err)
|
||||
assert.Len(t, output, 0)
|
||||
assert.Contains(t, err.Error(), `Required flag "model" not set`)
|
||||
})
|
||||
t.Run("UpdateWithNoMake", func(t *testing.T) {
|
||||
// Run command with test context.
|
||||
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", `--model=K-1`})
|
||||
assert.Error(t, err)
|
||||
assert.Len(t, output, 0)
|
||||
assert.Contains(t, err.Error(), `Required flag "make" not set`)
|
||||
})
|
||||
t.Run("UpdateWithEmptyMakeAndModel", func(t *testing.T) {
|
||||
// Explicit empty strings satisfy the Required flag check, so the guard in UpdateMakeModel
|
||||
// must reject them to prevent blanking a camera.
|
||||
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=", "--model="})
|
||||
assert.Error(t, err)
|
||||
assert.Len(t, output, 0)
|
||||
assert.Contains(t, err.Error(), "make and model must not be empty")
|
||||
var exitErr cli.ExitCoder
|
||||
if assert.ErrorAs(t, err, &exitErr) {
|
||||
assert.Equal(t, 1, exitErr.ExitCode())
|
||||
}
|
||||
})
|
||||
t.Run("UpdateValid", func(t *testing.T) {
|
||||
defer func() {
|
||||
entity.FlushCameraCache()
|
||||
assert.NoError(t, entity.Db().Save(entity.CameraFixtures.Pointer("canon-eos-7d")).Error)
|
||||
}()
|
||||
// Run command with test context.
|
||||
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=Pentax", `--model=K-1`})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check command output for plausibility.
|
||||
assert.Contains(t, output, "Updated At")
|
||||
assert.Contains(t, output, "PENTAX K-1")
|
||||
assert.Contains(t, output, "1000002")
|
||||
})
|
||||
}
|
||||
|
|
@ -61,6 +61,8 @@ var PhotoPrism = []*cli.Command{
|
|||
DownloadCommand,
|
||||
VisionCommands,
|
||||
FacesCommands,
|
||||
CamerasCommand,
|
||||
LensesCommand,
|
||||
PlacesCommands,
|
||||
PurgeCommand,
|
||||
CleanUpCommand,
|
||||
|
|
@ -85,7 +87,6 @@ var PhotoPrism = []*cli.Command{
|
|||
EditionCommand,
|
||||
ShowConfigCommand,
|
||||
ConnectCommand,
|
||||
LensesCommand,
|
||||
}
|
||||
|
||||
// CountFlag represents a CLI flag to limit the number of report rows.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
)
|
||||
|
|
@ -83,6 +84,18 @@ func TestLensesCommand(t *testing.T) {
|
|||
assert.Len(t, output, 0)
|
||||
assert.Contains(t, err.Error(), `Required flag "make" not set`)
|
||||
})
|
||||
t.Run("UpdateWithEmptyMakeAndModel", func(t *testing.T) {
|
||||
// Explicit empty strings satisfy the Required flag check, so the guard in UpdateMakeModel
|
||||
// must reject them to prevent blanking a lens.
|
||||
output, err := RunWithTestContext(LensesCommand, []string{"lenses", "update", "--id=1000002", "--make=", "--model="})
|
||||
assert.Error(t, err)
|
||||
assert.Len(t, output, 0)
|
||||
assert.Contains(t, err.Error(), "make and model must not be empty")
|
||||
var exitErr cli.ExitCoder
|
||||
if assert.ErrorAs(t, err, &exitErr) {
|
||||
assert.Equal(t, 1, exitErr.ExitCode())
|
||||
}
|
||||
})
|
||||
t.Run("UpdateValid", func(t *testing.T) {
|
||||
defer assert.NoError(t, entity.Db().Save(entity.LensFixtures.Pointer("4-37")).Error)
|
||||
// Run command with test context.
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
|
|||
Folders: true,
|
||||
Import: true,
|
||||
Labels: true,
|
||||
Cameras: true,
|
||||
Lenses: true,
|
||||
Library: true,
|
||||
Logs: true,
|
||||
|
|
@ -213,6 +214,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
|
|||
Folders: true,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Cameras: false,
|
||||
Lenses: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
|
|
@ -255,6 +257,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
|
|||
Folders: true,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Cameras: false,
|
||||
Lenses: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
|
|
@ -289,6 +292,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
|
|||
assert.False(t, f.Calendar)
|
||||
assert.False(t, f.Moments)
|
||||
assert.False(t, f.Labels)
|
||||
assert.False(t, f.Cameras)
|
||||
assert.False(t, f.Lenses)
|
||||
assert.False(t, f.People)
|
||||
assert.False(t, f.Settings)
|
||||
|
|
@ -334,6 +338,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.True(t, f.Calendar)
|
||||
assert.True(t, f.Moments)
|
||||
assert.True(t, f.Labels)
|
||||
assert.True(t, f.Cameras)
|
||||
assert.True(t, f.Lenses)
|
||||
assert.True(t, f.People)
|
||||
assert.True(t, f.Settings)
|
||||
|
|
@ -367,6 +372,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.True(t, f.Calendar)
|
||||
assert.True(t, f.Moments)
|
||||
assert.True(t, f.Labels)
|
||||
assert.True(t, f.Cameras)
|
||||
assert.True(t, f.Lenses)
|
||||
assert.True(t, f.People)
|
||||
assert.True(t, f.Settings)
|
||||
|
|
@ -399,6 +405,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.True(t, f.Albums)
|
||||
assert.False(t, f.Moments)
|
||||
assert.False(t, f.Labels)
|
||||
assert.False(t, f.Cameras)
|
||||
assert.False(t, f.Lenses)
|
||||
assert.False(t, f.People)
|
||||
assert.False(t, f.Settings)
|
||||
|
|
@ -433,6 +440,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.True(t, f.Moments)
|
||||
assert.True(t, f.Folders)
|
||||
assert.False(t, f.Labels)
|
||||
assert.False(t, f.Cameras)
|
||||
assert.False(t, f.Lenses)
|
||||
assert.False(t, f.People)
|
||||
assert.False(t, f.Settings)
|
||||
|
|
@ -467,6 +475,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.True(t, f.Moments)
|
||||
assert.True(t, f.Folders)
|
||||
assert.False(t, f.Labels)
|
||||
assert.False(t, f.Cameras)
|
||||
assert.False(t, f.Lenses)
|
||||
assert.False(t, f.People)
|
||||
assert.False(t, f.Settings)
|
||||
|
|
@ -500,6 +509,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.False(t, f.Albums)
|
||||
assert.False(t, f.Moments)
|
||||
assert.False(t, f.Labels)
|
||||
assert.False(t, f.Cameras)
|
||||
assert.False(t, f.Lenses)
|
||||
assert.False(t, f.People)
|
||||
assert.False(t, f.Settings)
|
||||
|
|
@ -531,6 +541,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.True(t, f.Calendar)
|
||||
assert.True(t, f.Moments)
|
||||
assert.True(t, f.Labels)
|
||||
assert.True(t, f.Cameras)
|
||||
assert.True(t, f.Lenses)
|
||||
assert.True(t, f.People)
|
||||
assert.True(t, f.Settings)
|
||||
|
|
@ -564,6 +575,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.False(t, f.Moments)
|
||||
assert.False(t, f.Folders)
|
||||
assert.False(t, f.Labels)
|
||||
assert.False(t, f.Cameras)
|
||||
assert.False(t, f.Lenses)
|
||||
assert.False(t, f.People)
|
||||
assert.False(t, f.Settings)
|
||||
|
|
@ -596,6 +608,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.False(t, f.Calendar)
|
||||
assert.False(t, f.Moments)
|
||||
assert.False(t, f.Labels)
|
||||
assert.False(t, f.Cameras)
|
||||
assert.False(t, f.Lenses)
|
||||
assert.False(t, f.People)
|
||||
assert.True(t, f.Settings)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ func (s *Settings) ApplyACL(list acl.ACL, role acl.Role) *Settings {
|
|||
m.Features.Favorites = s.Features.Favorites && list.AllowAny(acl.ResourceFavorites, role, acl.Permissions{acl.ActionSearch})
|
||||
m.Features.Folders = s.Features.Folders && list.AllowAny(acl.ResourceFolders, role, acl.Permissions{acl.ActionSearch})
|
||||
m.Features.Labels = s.Features.Labels && list.AllowAny(acl.ResourceLabels, role, acl.Permissions{acl.ActionSearch})
|
||||
m.Features.Cameras = s.Features.Cameras && list.AllowAny(acl.ResourceCameras, role, acl.Permissions{acl.ActionSearch})
|
||||
m.Features.Lenses = s.Features.Lenses && list.AllowAny(acl.ResourceLenses, role, acl.Permissions{acl.ActionSearch})
|
||||
m.Features.Calendar = s.Features.Calendar && list.AllowAny(acl.ResourceCalendar, role, acl.Permissions{acl.ActionSearch})
|
||||
m.Features.Moments = s.Features.Moments && list.AllowAny(acl.ResourceMoments, role, acl.Permissions{acl.ActionSearch})
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ func TestSettings_ApplyACL(t *testing.T) {
|
|||
Folders: true,
|
||||
Import: true,
|
||||
Labels: true,
|
||||
Cameras: true,
|
||||
Lenses: true,
|
||||
Library: true,
|
||||
Logs: true,
|
||||
|
|
@ -70,6 +71,7 @@ func TestSettings_ApplyACL(t *testing.T) {
|
|||
Folders: true,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Cameras: false,
|
||||
Lenses: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type FeatureSettings struct {
|
|||
Folders bool `json:"folders" yaml:"Folders"`
|
||||
Import bool `json:"import" yaml:"Import"`
|
||||
Labels bool `json:"labels" yaml:"Labels"`
|
||||
Cameras bool `json:"cameras" yaml:"Cameras"`
|
||||
Lenses bool `json:"lenses" yaml:"Lenses"`
|
||||
Library bool `json:"library" yaml:"Library"`
|
||||
Logs bool `json:"logs" yaml:"Logs"`
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ func TestInitDefaultFeatures_DisableList(t *testing.T) {
|
|||
DefaultFeatures = initDefaultFeatures()
|
||||
})
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", "Upload, videos share batch-edit labels lenses")
|
||||
_ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", "Upload, videos share batch-edit labels cameras lenses")
|
||||
DefaultFeatures = initDefaultFeatures()
|
||||
|
||||
assert.False(t, DefaultFeatures.Upload)
|
||||
|
|
@ -29,6 +29,7 @@ func TestInitDefaultFeatures_DisableList(t *testing.T) {
|
|||
assert.False(t, DefaultFeatures.Share)
|
||||
assert.False(t, DefaultFeatures.BatchEdit)
|
||||
assert.False(t, DefaultFeatures.Labels)
|
||||
assert.False(t, DefaultFeatures.Cameras)
|
||||
assert.False(t, DefaultFeatures.Lenses)
|
||||
|
||||
// unaffected feature stays enabled
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ func (s *Settings) ApplyScope(scope string) *Settings {
|
|||
m.Features.Favorites = s.Features.Favorites && scopes.Contains(acl.ResourceFavorites.String())
|
||||
m.Features.Folders = s.Features.Folders && scopes.Contains(acl.ResourceFolders.String())
|
||||
m.Features.Labels = s.Features.Labels && scopes.Contains(acl.ResourceLabels.String())
|
||||
m.Features.Cameras = s.Features.Cameras && scopes.Contains(acl.ResourceCameras.String())
|
||||
m.Features.Lenses = s.Features.Lenses && scopes.Contains(acl.ResourceLenses.String())
|
||||
m.Features.Calendar = s.Features.Calendar && scopes.Contains(acl.ResourceCalendar.String())
|
||||
m.Features.Moments = s.Features.Moments && scopes.Contains(acl.ResourceMoments.String())
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ func TestSettings_ApplyScope(t *testing.T) {
|
|||
Folders: true,
|
||||
Import: true,
|
||||
Labels: true,
|
||||
Cameras: true,
|
||||
Lenses: true,
|
||||
Library: true,
|
||||
Logs: true,
|
||||
|
|
@ -74,6 +75,7 @@ func TestSettings_ApplyScope(t *testing.T) {
|
|||
Folders: false,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Cameras: false,
|
||||
Lenses: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
|
|
@ -117,6 +119,7 @@ func TestSettings_ApplyScope(t *testing.T) {
|
|||
Folders: true,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Cameras: false,
|
||||
Lenses: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
|
|
@ -159,6 +162,7 @@ func TestSettings_ApplyScope(t *testing.T) {
|
|||
Folders: true,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Cameras: false,
|
||||
Lenses: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
|
|
@ -201,6 +205,7 @@ func TestSettings_ApplyScope(t *testing.T) {
|
|||
Folders: false,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Cameras: false,
|
||||
Lenses: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ Features:
|
|||
Folders: true
|
||||
Import: true
|
||||
Labels: true
|
||||
Cameras: true
|
||||
Lenses: true
|
||||
Library: true
|
||||
Logs: true
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ulule/deepcopier"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
|
@ -35,6 +39,7 @@ func (Camera) TableName() string {
|
|||
return "cameras"
|
||||
}
|
||||
|
||||
// UnknownCamera is the placeholder used when no camera make or model is known.
|
||||
var UnknownCamera = Camera{
|
||||
CameraSlug: UnknownID,
|
||||
CameraName: "Unknown",
|
||||
|
|
@ -182,3 +187,58 @@ func (m *Camera) Mobile() bool {
|
|||
func (m *Camera) Unknown() bool {
|
||||
return m.CameraSlug == "" || m.CameraSlug == UnknownCamera.CameraSlug
|
||||
}
|
||||
|
||||
// UpdateMakeModel updates the make and model of an existing camera, e.g. to fix entries that
|
||||
// ExifTool decodes with a missing or garbled make.
|
||||
// The camera slug is intentionally left unchanged so existing photo references and the unique slug
|
||||
// index are preserved across renames.
|
||||
func (m *Camera) UpdateMakeModel(makeName, modelName string) error {
|
||||
if m.ID == 0 {
|
||||
return fmt.Errorf("empty id")
|
||||
}
|
||||
|
||||
makeName = strings.TrimSpace(makeName)
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
|
||||
if makeName == "" || modelName == "" {
|
||||
return fmt.Errorf("make and model must not be empty")
|
||||
}
|
||||
|
||||
cam := NewCamera(makeName, modelName)
|
||||
// Override the changeable fields.
|
||||
m.CameraMake = cam.CameraMake
|
||||
m.CameraModel = cam.CameraModel
|
||||
m.CameraName = cam.CameraName
|
||||
m.CameraType = cam.CameraType
|
||||
|
||||
cameraMutex.Lock()
|
||||
defer cameraMutex.Unlock()
|
||||
if err := Db().Save(m).Error; err != nil {
|
||||
return err
|
||||
} else {
|
||||
if !m.Unknown() {
|
||||
event.EntitiesUpdated("cameras", []*Camera{m})
|
||||
|
||||
event.Publish("count.cameras", event.Data{
|
||||
"count": 1,
|
||||
})
|
||||
}
|
||||
cameraCache.SetDefault(m.CameraSlug, m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveForm validates the form, copies its data into the camera, and persists it.
|
||||
func (m *Camera) SaveForm(f *form.Camera) error {
|
||||
if f == nil {
|
||||
return fmt.Errorf("form is nil")
|
||||
} else if err := f.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := deepcopier.Copy(m).From(f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.UpdateMakeModel(f.CameraMake, f.CameraModel)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
var cameraCache = gc.New(time.Hour, 15*time.Minute)
|
||||
|
||||
// FlushCameraCache removes all cached cameras.
|
||||
func FlushCameraCache() {
|
||||
cameraCache.Flush()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package entity
|
||||
|
||||
// Camera make name constants used for normalization and device-type detection.
|
||||
const (
|
||||
MakeNone = ""
|
||||
MakeAcer = "Acer"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package entity
|
||||
|
||||
// Camera model name constants used for normalization and device-type detection.
|
||||
const (
|
||||
ModelNone = ""
|
||||
ModelUnknown = "Unknown"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
)
|
||||
|
||||
func TestFirstOrCreateCamera(t *testing.T) {
|
||||
|
|
@ -253,3 +255,76 @@ func TestCamera_Mobile(t *testing.T) {
|
|||
assert.True(t, camera.Mobile())
|
||||
})
|
||||
}
|
||||
|
||||
func TestCamera_UpdateMakeModel(t *testing.T) {
|
||||
t.Run("ExistingCamera", func(t *testing.T) {
|
||||
fixture := "canon-eos-7d"
|
||||
camera := NewCamera(CameraFixtures.Get(fixture).CameraMake, CameraFixtures.Get(fixture).CameraModel)
|
||||
|
||||
result := FirstOrCreateCamera(camera)
|
||||
|
||||
defer assert.NoError(t, UnscopedDb().Save(CameraFixtures.Pointer(fixture)).Error)
|
||||
makeName := "Pentax"
|
||||
modelName := "K-1"
|
||||
err := result.UpdateMakeModel(makeName, modelName)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, CameraFixtures.Get(fixture).ID, result.ID)
|
||||
assert.Equal(t, CameraMakes[makeName], result.CameraMake)
|
||||
assert.Equal(t, modelName, result.CameraModel)
|
||||
assert.Equal(t, CameraFixtures.Get(fixture).CameraSlug, result.CameraSlug) // Slug is preserved across renames.
|
||||
assert.Equal(t, CameraMakes[makeName]+" "+modelName, result.CameraName)
|
||||
})
|
||||
t.Run("NewCamera", func(t *testing.T) {
|
||||
setup := NewCamera("", "9 99")
|
||||
camera := FirstOrCreateCamera(setup)
|
||||
defer assert.NoError(t, UnscopedDb().Delete(&Camera{}, "id = ?", camera.ID).Error)
|
||||
makeName := "Pentax"
|
||||
modelName := "K-1"
|
||||
err := camera.UpdateMakeModel(makeName, modelName)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, CameraMakes[makeName], camera.CameraMake)
|
||||
assert.Equal(t, modelName, camera.CameraModel)
|
||||
assert.Equal(t, "9-99", camera.CameraSlug) // Slug is preserved across renames.
|
||||
assert.Equal(t, CameraMakes[makeName]+" "+modelName, camera.CameraName)
|
||||
})
|
||||
t.Run("NotExistingCamera", func(t *testing.T) {
|
||||
camera := NewCamera("", "9 98")
|
||||
err := camera.UpdateMakeModel("Pentax", "K-3")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
t.Run("EmptyMake", func(t *testing.T) {
|
||||
camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID, CameraMake: "Canon", CameraModel: "EOS 7D", CameraName: "Canon EOS 7D", CameraSlug: "canon-eos-7d"}
|
||||
err := camera.UpdateMakeModel(" ", "EOS 7D")
|
||||
assert.Error(t, err)
|
||||
// The guard returns before any mutation, so existing values must be untouched.
|
||||
assert.Equal(t, "Canon", camera.CameraMake)
|
||||
assert.Equal(t, "EOS 7D", camera.CameraModel)
|
||||
})
|
||||
t.Run("EmptyModel", func(t *testing.T) {
|
||||
camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID, CameraMake: "Canon", CameraModel: "EOS 7D", CameraName: "Canon EOS 7D", CameraSlug: "canon-eos-7d"}
|
||||
err := camera.UpdateMakeModel("Canon", "")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "Canon", camera.CameraMake)
|
||||
assert.Equal(t, "EOS 7D", camera.CameraModel)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCamera_SaveForm(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
fixture := "canon-eos-7d"
|
||||
camera := FirstOrCreateCamera(NewCamera(CameraFixtures.Get(fixture).CameraMake, CameraFixtures.Get(fixture).CameraModel))
|
||||
defer assert.NoError(t, UnscopedDb().Save(CameraFixtures.Pointer(fixture)).Error)
|
||||
err := camera.SaveForm(&form.Camera{CameraMake: "Pentax", CameraModel: "K-1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, CameraMakes["Pentax"], camera.CameraMake)
|
||||
assert.Equal(t, "K-1", camera.CameraModel)
|
||||
})
|
||||
t.Run("NilForm", func(t *testing.T) {
|
||||
camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID}
|
||||
assert.Error(t, camera.SaveForm(nil))
|
||||
})
|
||||
t.Run("EmptyMake", func(t *testing.T) {
|
||||
camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID}
|
||||
assert.Error(t, camera.SaveForm(&form.Camera{CameraMake: "", CameraModel: "K-1"}))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ func (Lens) TableName() string {
|
|||
return "lenses"
|
||||
}
|
||||
|
||||
// UnknownLens is the placeholder used when no lens make or model is known.
|
||||
var UnknownLens = Lens{
|
||||
LensSlug: UnknownID,
|
||||
LensName: "Unknown",
|
||||
|
|
@ -158,21 +159,31 @@ func (m *Lens) Unknown() bool {
|
|||
return m.LensSlug == "" || m.LensSlug == UnknownLens.LensSlug
|
||||
}
|
||||
|
||||
// UpdateMakeModel updates the make and model for an existing lens. Primarily intended for Pentax lens.
|
||||
// UpdateMakeModel updates the make and model of an existing lens, e.g. to fix Pentax models that
|
||||
// ExifTool decodes as a numeric "4 38".
|
||||
// The lens slug is intentionally left unchanged so existing photo references and the unique slug
|
||||
// index are preserved across renames.
|
||||
func (m *Lens) UpdateMakeModel(makeName, modelName string) error {
|
||||
if m.ID == 0 {
|
||||
return fmt.Errorf("empty id")
|
||||
}
|
||||
|
||||
makeName = strings.TrimSpace(makeName)
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
|
||||
if makeName == "" || modelName == "" {
|
||||
return fmt.Errorf("make and model must not be empty")
|
||||
}
|
||||
|
||||
l := NewLens(makeName, modelName)
|
||||
// Override the changeable fields
|
||||
// Override the changeable fields.
|
||||
m.LensMake = l.LensMake
|
||||
m.LensModel = l.LensModel
|
||||
m.LensName = l.LensName
|
||||
|
||||
lensMutex.Lock()
|
||||
defer lensMutex.Unlock()
|
||||
if err := Db().Save(&m).Error; err != nil {
|
||||
if err := Db().Save(m).Error; err != nil {
|
||||
return err
|
||||
} else {
|
||||
if !m.Unknown() {
|
||||
|
|
@ -187,12 +198,12 @@ func (m *Lens) UpdateMakeModel(makeName, modelName string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// SaveForm copies validated form data into the label and persists it.
|
||||
// SaveForm validates the form, copies its data into the lens, and persists it.
|
||||
func (m *Lens) SaveForm(f *form.Lens) error {
|
||||
if f == nil {
|
||||
return fmt.Errorf("form is nil")
|
||||
} else if f.LensMake == "" || txt.Slug(f.LensModel) == "" {
|
||||
return fmt.Errorf("make and model must not be empty")
|
||||
} else if err := f.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := deepcopier.Copy(m).From(f); err != nil {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
var lensCache = gc.New(time.Hour, 15*time.Minute)
|
||||
|
||||
// FlushLensCache removes all cached lenses.
|
||||
func FlushLensCache() {
|
||||
lensCache.Flush()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
)
|
||||
|
||||
func TestNewLens(t *testing.T) {
|
||||
|
|
@ -145,4 +147,40 @@ func TestLensUpdateMakeModel(t *testing.T) {
|
|||
err := lens.UpdateMakeModel("Pentax", "smc PENTAX-FA 31mm F1.8 AL Limited")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
t.Run("EmptyMake", func(t *testing.T) {
|
||||
lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID, LensMake: "Apple", LensModel: "F380", LensName: "Apple F380", LensSlug: "lens-f-380"}
|
||||
err := lens.UpdateMakeModel(" ", "F380")
|
||||
assert.Error(t, err)
|
||||
// The guard returns before any mutation, so existing values must be untouched.
|
||||
assert.Equal(t, "Apple", lens.LensMake)
|
||||
assert.Equal(t, "F380", lens.LensModel)
|
||||
})
|
||||
t.Run("EmptyModel", func(t *testing.T) {
|
||||
lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID, LensMake: "Apple", LensModel: "F380", LensName: "Apple F380", LensSlug: "lens-f-380"}
|
||||
err := lens.UpdateMakeModel("Apple", "")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "Apple", lens.LensMake)
|
||||
assert.Equal(t, "F380", lens.LensModel)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLens_SaveForm(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
lens := Lens{}
|
||||
assert.NoError(t, UnscopedDb().First(&lens, "id = ?", LensFixtures.Get("lens-f-380").ID).Error)
|
||||
defer assert.NoError(t, UnscopedDb().Save(LensFixtures.Pointer("lens-f-380")).Error)
|
||||
err := lens.SaveForm(&form.Lens{LensMake: "Sigma", LensModel: "85mm F1.4"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, CameraMakes["Sigma"], lens.LensMake) // NewLens normalizes the make.
|
||||
assert.Equal(t, "85mm F1.4", lens.LensModel)
|
||||
assert.Equal(t, "lens-f-380", lens.LensSlug) // Slug is preserved across renames.
|
||||
})
|
||||
t.Run("NilForm", func(t *testing.T) {
|
||||
lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID}
|
||||
assert.Error(t, lens.SaveForm(nil))
|
||||
})
|
||||
t.Run("EmptyMake", func(t *testing.T) {
|
||||
lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID}
|
||||
assert.Error(t, lens.SaveForm(&form.Lens{LensMake: "", LensModel: "85mm F1.4"}))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
35
internal/entity/query/camera.go
Normal file
35
internal/entity/query/camera.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package query
|
||||
|
||||
import (
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
)
|
||||
|
||||
// FindCameraBySlug returns an existing entity if exists.
|
||||
func FindCameraBySlug(slug string) *entity.Camera {
|
||||
if slug == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c := entity.Camera{}
|
||||
|
||||
if err := Db().Where("camera_slug = ?", slug).First(&c).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
// FindCameraByID returns an existing entity if exists.
|
||||
func FindCameraByID(id uint) *entity.Camera {
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
c := entity.Camera{}
|
||||
|
||||
if err := Db().Where("id = ?", id).First(&c).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &c
|
||||
}
|
||||
47
internal/entity/query/camera_test.go
Normal file
47
internal/entity/query/camera_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
)
|
||||
|
||||
func TestFindCameraBySlug(t *testing.T) {
|
||||
t.Run("ExistingCamera", func(t *testing.T) {
|
||||
camera := FindCameraBySlug(entity.CameraFixtures.Get("canon-eos-7d").CameraSlug)
|
||||
assert.NotNil(t, camera)
|
||||
if camera != nil {
|
||||
assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").ID, camera.ID)
|
||||
assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").CameraModel, camera.CameraModel)
|
||||
}
|
||||
})
|
||||
t.Run("NotExistingCamera", func(t *testing.T) {
|
||||
camera := FindCameraBySlug("IAmNotValid")
|
||||
assert.Nil(t, camera)
|
||||
})
|
||||
t.Run("EmptySlug", func(t *testing.T) {
|
||||
camera := FindCameraBySlug("")
|
||||
assert.Nil(t, camera)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindCameraByID(t *testing.T) {
|
||||
t.Run("ExistingCamera", func(t *testing.T) {
|
||||
camera := FindCameraByID(entity.CameraFixtures.Get("canon-eos-7d").ID)
|
||||
assert.NotNil(t, camera)
|
||||
if camera != nil {
|
||||
assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").ID, camera.ID)
|
||||
assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").CameraModel, camera.CameraModel)
|
||||
}
|
||||
})
|
||||
t.Run("NotExistingCamera", func(t *testing.T) {
|
||||
camera := FindCameraByID(99885541348)
|
||||
assert.Nil(t, camera)
|
||||
})
|
||||
t.Run("ZeroID", func(t *testing.T) {
|
||||
camera := FindCameraByID(0)
|
||||
assert.Nil(t, camera)
|
||||
})
|
||||
}
|
||||
|
|
@ -34,12 +34,8 @@ func PurgeOrphans() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Remove unused camera lenses.
|
||||
if err := PurgeOrphanLenses(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
// Remove unused lenses.
|
||||
return PurgeOrphanLenses()
|
||||
}
|
||||
|
||||
// PurgeOrphanFiles removes files without a photo from the index.
|
||||
|
|
|
|||
20
internal/entity/search/camera_results.go
Normal file
20
internal/entity/search/camera_results.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Camera represents a camera search result.
|
||||
type Camera struct {
|
||||
ID uint `json:"ID"`
|
||||
CameraSlug string `json:"Slug"`
|
||||
CameraName string `json:"Name"`
|
||||
CameraMake string `json:"Make"`
|
||||
CameraModel string `json:"Model"`
|
||||
CameraType string `json:"Type"`
|
||||
CameraDescription string `json:"Description"`
|
||||
CameraNotes string `json:"Notes"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
DeletedAt time.Time `json:"DeletedAt"`
|
||||
}
|
||||
57
internal/entity/search/cameras.go
Normal file
57
internal/entity/search/cameras.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// Cameras searches cameras based on their name.
|
||||
func Cameras(frm form.SearchCameras) (results []Camera, err error) {
|
||||
if err = frm.ParseQueryString(); err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
s := Db()
|
||||
|
||||
// Base query.
|
||||
s = s.Table("cameras").
|
||||
Select(`cameras.*`)
|
||||
|
||||
// Filter cameras without make.
|
||||
if frm.NoMake {
|
||||
s = s.Where("cameras.camera_make = ''")
|
||||
}
|
||||
|
||||
// Limit result count.
|
||||
if frm.Count > 0 && frm.Count <= MaxResults {
|
||||
s = s.Limit(frm.Count).Offset(frm.Offset)
|
||||
} else {
|
||||
s = s.Limit(MaxResults).Offset(frm.Offset)
|
||||
}
|
||||
|
||||
// Set sort order.
|
||||
s = s.Order(OrderExpr("cameras.camera_make ASC, cameras.camera_model ASC, cameras.camera_slug ASC", frm.Reverse))
|
||||
|
||||
if frm.ID != "" {
|
||||
s = s.Where("cameras.id IN (?)", strings.Split(frm.ID, txt.Or))
|
||||
|
||||
if result := s.Scan(&results); result.Error != nil {
|
||||
return results, result.Error
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
if frm.Query != "" {
|
||||
likeString := SqlParam(frm.Query, "%", "%")
|
||||
s = s.Where("cameras.camera_name LIKE ? OR cameras.camera_make LIKE ? OR cameras.camera_model LIKE ?", likeString, likeString, likeString)
|
||||
}
|
||||
|
||||
if result := s.Scan(&results); result.Error != nil {
|
||||
return results, result.Error
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
125
internal/entity/search/cameras_test.go
Normal file
125
internal/entity/search/cameras_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
)
|
||||
|
||||
func TestCameras(t *testing.T) {
|
||||
t.Run("SearchWithQuery", func(t *testing.T) {
|
||||
query := form.NewCameraSearch("q:Canon")
|
||||
query.Count = 1005
|
||||
result, err := Cameras(query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.LessOrEqual(t, 2, len(result))
|
||||
|
||||
for _, r := range result {
|
||||
assert.IsType(t, Camera{}, r)
|
||||
assert.NotEmpty(t, r.ID)
|
||||
assert.NotEmpty(t, r.CameraName)
|
||||
assert.NotEmpty(t, r.CameraSlug)
|
||||
assert.True(t, strings.Contains(strings.ToLower(r.CameraName), "canon") || strings.Contains(strings.ToLower(r.CameraMake), "canon") || strings.Contains(strings.ToLower(r.CameraModel), "canon"))
|
||||
|
||||
if fix, ok := entity.CameraFixtures[r.CameraSlug]; ok {
|
||||
assert.Equal(t, fix.CameraName, r.CameraName)
|
||||
assert.Equal(t, fix.CameraSlug, r.CameraSlug)
|
||||
assert.Equal(t, fix.CameraMake, r.CameraMake)
|
||||
assert.Equal(t, fix.CameraModel, r.CameraModel)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("SearchForString", func(t *testing.T) {
|
||||
query := form.NewCameraSearch("Q:EOS 5D")
|
||||
query.Count = 1005
|
||||
result, err := Cameras(query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.LessOrEqual(t, 1, len(result))
|
||||
|
||||
for _, r := range result {
|
||||
assert.IsType(t, Camera{}, r)
|
||||
assert.NotEmpty(t, r.ID)
|
||||
assert.NotEmpty(t, r.CameraName)
|
||||
assert.NotEmpty(t, r.CameraSlug)
|
||||
assert.NotEmpty(t, r.CameraModel)
|
||||
}
|
||||
})
|
||||
t.Run("SearchForNoMake", func(t *testing.T) {
|
||||
query := form.NewCameraSearch("NoMake:true")
|
||||
query.Count = 15
|
||||
result, err := Cameras(query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.LessOrEqual(t, 1, len(result))
|
||||
|
||||
for _, r := range result {
|
||||
assert.IsType(t, Camera{}, r)
|
||||
assert.NotEmpty(t, r.ID)
|
||||
assert.NotEmpty(t, r.CameraName)
|
||||
assert.NotEmpty(t, r.CameraSlug)
|
||||
assert.Empty(t, r.CameraMake)
|
||||
}
|
||||
})
|
||||
t.Run("SearchWithEmptyQuery", func(t *testing.T) {
|
||||
query := form.NewCameraSearch("")
|
||||
result, err := Cameras(query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.LessOrEqual(t, 6, len(result))
|
||||
})
|
||||
t.Run("SearchWithReverse", func(t *testing.T) {
|
||||
query := form.NewCameraSearch("")
|
||||
query.Reverse = true
|
||||
result, err := Cameras(query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.LessOrEqual(t, 6, len(result))
|
||||
})
|
||||
t.Run("SearchForNoMakeAndQueryNoResults", func(t *testing.T) {
|
||||
query := form.NewCameraSearch("Canon")
|
||||
query.NoMake = true
|
||||
result, err := Cameras(query)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("SearchWithInvalidQueryString", func(t *testing.T) {
|
||||
query := form.NewCameraSearch("xxx:bla")
|
||||
result, err := Cameras(query)
|
||||
|
||||
assert.Error(t, err, "unknown filter")
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("SearchForId", func(t *testing.T) {
|
||||
f := form.SearchCameras{
|
||||
ID: "1000000|1000001",
|
||||
Count: 0,
|
||||
}
|
||||
|
||||
result, err := Cameras(f)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
}
|
||||
39
internal/form/camera.go
Normal file
39
internal/form/camera.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package form
|
||||
|
||||
import (
|
||||
"github.com/ulule/deepcopier"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// Camera represents a camera edit form.
|
||||
type Camera struct {
|
||||
CameraMake string `json:"Make"`
|
||||
CameraModel string `json:"Model"`
|
||||
}
|
||||
|
||||
// NewCamera creates a new form struct based on the interface values.
|
||||
func NewCamera(m any) (*Camera, error) {
|
||||
frm := &Camera{}
|
||||
err := deepcopier.Copy(m).To(frm)
|
||||
return frm, err
|
||||
}
|
||||
|
||||
// Validate returns an error if any form values are invalid.
|
||||
func (frm *Camera) Validate() error {
|
||||
cameraMake := txt.Clip(frm.CameraMake, txt.ClipName)
|
||||
cameraModel := txt.Clip(frm.CameraModel, txt.ClipName)
|
||||
|
||||
if cameraMake == "" || cameraModel == "" {
|
||||
return i18n.Error(i18n.ErrInvalidName)
|
||||
}
|
||||
|
||||
cameraSlug := txt.Slug(cameraMake + " " + cameraModel)
|
||||
|
||||
if cameraSlug == "" {
|
||||
return i18n.Error(i18n.ErrInvalidName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
44
internal/form/camera_test.go
Normal file
44
internal/form/camera_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package form
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCamera(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var c = struct {
|
||||
CameraMake string
|
||||
CameraModel string
|
||||
}{
|
||||
CameraMake: "New Make",
|
||||
CameraModel: "New Model",
|
||||
}
|
||||
|
||||
result, err := NewCamera(c)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.IsType(t, &Camera{}, result)
|
||||
assert.Equal(t, "New Make", result.CameraMake)
|
||||
assert.Equal(t, "New Model", result.CameraModel)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCamera_Validate(t *testing.T) {
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
frm := &Camera{CameraMake: "Canon", CameraModel: "EOS 5D"}
|
||||
assert.NoError(t, frm.Validate())
|
||||
})
|
||||
t.Run("EmptyMake", func(t *testing.T) {
|
||||
frm := &Camera{CameraMake: "", CameraModel: "EOS 5D"}
|
||||
assert.Error(t, frm.Validate())
|
||||
})
|
||||
t.Run("EmptyModel", func(t *testing.T) {
|
||||
frm := &Camera{CameraMake: "Canon", CameraModel: ""}
|
||||
assert.Error(t, frm.Validate())
|
||||
})
|
||||
}
|
||||
33
internal/form/search_cameras.go
Normal file
33
internal/form/search_cameras.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package form
|
||||
|
||||
// SearchCameras represents search form fields for command or "/api/v1/cameras".
|
||||
type SearchCameras struct {
|
||||
Query string `form:"q"`
|
||||
ID string `form:"id"`
|
||||
Slug string `form:"slug"`
|
||||
Name string `form:"name"`
|
||||
NoMake bool `form:"nomake"`
|
||||
Count int `form:"count" binding:"required" serialize:"-"`
|
||||
Offset int `form:"offset" serialize:"-"`
|
||||
Reverse bool `form:"reverse" serialize:"-"`
|
||||
}
|
||||
|
||||
// GetQuery returns the current search query string.
|
||||
func (f *SearchCameras) GetQuery() string {
|
||||
return f.Query
|
||||
}
|
||||
|
||||
// SetQuery stores the raw query string.
|
||||
func (f *SearchCameras) SetQuery(q string) {
|
||||
f.Query = q
|
||||
}
|
||||
|
||||
// ParseQueryString deserializes the query string into form fields.
|
||||
func (f *SearchCameras) ParseQueryString() error {
|
||||
return ParseQueryString(f)
|
||||
}
|
||||
|
||||
// NewCameraSearch creates a SearchCameras form with the provided query.
|
||||
func NewCameraSearch(query string) SearchCameras {
|
||||
return SearchCameras{Query: query}
|
||||
}
|
||||
|
|
@ -185,6 +185,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||
api.GetFace(APIv1)
|
||||
api.UpdateFace(APIv1)
|
||||
|
||||
// Cameras.
|
||||
api.SearchCameras(APIv1)
|
||||
api.UpdateCamera(APIv1)
|
||||
|
||||
// Lenses.
|
||||
api.SearchLenses(APIv1)
|
||||
api.UpdateLens(APIv1)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const (
|
|||
ErrAccountNotFound
|
||||
ErrUserNotFound
|
||||
ErrLabelNotFound
|
||||
ErrCameraNotFound
|
||||
ErrLensNotFound
|
||||
ErrAlbumNotFound
|
||||
ErrSubjectNotFound
|
||||
|
|
@ -123,6 +124,7 @@ var Messages = MessageMap{
|
|||
ErrAccountNotFound: gettext("Account not found"),
|
||||
ErrUserNotFound: gettext("User not found"),
|
||||
ErrLabelNotFound: gettext("Label not found"),
|
||||
ErrCameraNotFound: gettext("Camera not found"),
|
||||
ErrLensNotFound: gettext("Lens not found"),
|
||||
ErrAlbumNotFound: gettext("Album not found"),
|
||||
ErrSubjectNotFound: gettext("Subject not found"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue