Skip to content

Commit

Permalink
Merge pull request #255 from tnc-ca-geo/update-camera-sn
Browse files Browse the repository at this point in the history
Update Camera Serial Numbers
  • Loading branch information
nathanielrindlaub authored Sep 18, 2024
2 parents 02955aa + 630c08b commit 238eba3
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 20 deletions.
11 changes: 11 additions & 0 deletions src/@types/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ export type Mutation = {
unregisterCamera?: Maybe<UnregisterCameraPayload>;
updateAutomationRules?: Maybe<UpdateAutomationRulesPayload>;
updateBatch?: Maybe<BatchPayload>;
updateCameraSerialNumber?: Maybe<Task>;
updateDeployment?: Maybe<Task>;
updateImageComment?: Maybe<ImageCommentsPayload>;
updateLabels?: Maybe<StandardPayload>;
Expand Down Expand Up @@ -736,6 +737,11 @@ export type MutationUpdateBatchArgs = {
};


export type MutationUpdateCameraSerialNumberArgs = {
input: UpdateCameraSerialNumberInput;
};


export type MutationUpdateDeploymentArgs = {
input: UpdateDeploymentInput;
};
Expand Down Expand Up @@ -1103,6 +1109,11 @@ export type UpdateBatchInput = {
uploadedFile?: InputMaybe<Scalars['String']['input']>;
};

export type UpdateCameraSerialNumberInput = {
cameraId: Scalars['ID']['input'];
newId: Scalars['String']['input'];
};

export type UpdateDeploymentInput = {
cameraId: Scalars['ID']['input'];
deploymentId: Scalars['ID']['input'];
Expand Down
28 changes: 15 additions & 13 deletions src/api/auth/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ const MANAGER = 'project_manager';
const MEMBER = 'project_member';
// const OBSERVER = 'project_observer';

const EXPORT_DATA_ROLES = [MANAGER, MEMBER];
const WRITE_OBJECTS_ROLES = [MANAGER, MEMBER];
const WRITE_VIEWS_ROLES = [MANAGER, MEMBER];
const WRITE_COMMENTS_ROLES = [MANAGER, MEMBER];
const READ_TASKS_ROLES = [MANAGER, MEMBER];
const WRITE_PROJECT_ROLES = [MANAGER];
const WRITE_IMAGES_ROLES = [MANAGER];
const DELETE_IMAGES_ROLES = [MANAGER];
const MANAGE_USERS_ROLES = [MANAGER];
const WRITE_DEPLOYMENTS_ROLES = [MANAGER];
const WRITE_AUTOMATION_RULES_ROLES = [MANAGER];
const WRITE_CAMERA_REGISTRATION_ROLES = [MANAGER];
const EXPORT_DATA_ROLES = [MANAGER, MEMBER];
const WRITE_OBJECTS_ROLES = [MANAGER, MEMBER];
const WRITE_VIEWS_ROLES = [MANAGER, MEMBER];
const WRITE_COMMENTS_ROLES = [MANAGER, MEMBER];
const READ_TASKS_ROLES = [MANAGER, MEMBER];
const WRITE_PROJECT_ROLES = [MANAGER];
const WRITE_IMAGES_ROLES = [MANAGER];
const DELETE_IMAGES_ROLES = [MANAGER];
const MANAGE_USERS_ROLES = [MANAGER];
const WRITE_DEPLOYMENTS_ROLES = [MANAGER];
const WRITE_AUTOMATION_RULES_ROLES = [MANAGER];
const WRITE_CAMERA_REGISTRATION_ROLES = [MANAGER];
const WRITE_CAMERA_SERIAL_NUMBER_ROLES = [MANAGER];

export {
READ_TASKS_ROLES,
Expand All @@ -27,5 +28,6 @@ export {
WRITE_IMAGES_ROLES,
WRITE_DEPLOYMENTS_ROLES,
WRITE_AUTOMATION_RULES_ROLES,
WRITE_CAMERA_REGISTRATION_ROLES
WRITE_CAMERA_REGISTRATION_ROLES,
WRITE_CAMERA_SERIAL_NUMBER_ROLES,
};
162 changes: 159 additions & 3 deletions src/api/db/models/Camera.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import mongoose from 'mongoose';
import mongoose, { HydratedDocument } from 'mongoose';
import GraphQLError, { InternalServerError, CameraRegistrationError } from '../../errors.js';
import WirelessCamera, { WirelessCameraSchema } from '../schemas/WirelessCamera.js';
import Images from '../schemas/Image.js';
import retry from 'async-retry';
import { WRITE_CAMERA_REGISTRATION_ROLES } from '../../auth/roles.js';
import {
WRITE_CAMERA_REGISTRATION_ROLES,
WRITE_CAMERA_SERIAL_NUMBER_ROLES,
} from '../../auth/roles.js';
import { ProjectModel } from './Project.js';
import { BaseAuthedModel, MethodParams, roleCheck, idMatch } from './utils.js';
import { Context } from '../../handler.js';
import type * as gql from '../../../@types/graphql.js';
import Project, { ProjectSchema } from '../schemas/Project.js';
import { TaskModel } from './Task.js';
import { TaskSchema } from '../schemas/Task.js';

const ObjectId = mongoose.Types.ObjectId;

Expand Down Expand Up @@ -229,6 +235,151 @@ export class CameraModel {
throw new InternalServerError(err as string);
}
}

static async updateSerialNumberTask(
input: gql.UpdateCameraSerialNumberInput,
context: Pick<Context, 'user' | 'config'>,
): Promise<HydratedDocument<TaskSchema>> {
try {
return await TaskModel.create(
{
type: 'UpdateSerialNumber',
projectId: context.user['curr_project'],
user: context.user.sub,
config: input,
},
context,
);
} catch (err) {
if (err instanceof GraphQLError) throw err;
throw new InternalServerError(err as string);
}
}

// NOTE: this method is called by the async task handler
static async updateSerialNumber(
input: gql.UpdateCameraSerialNumberInput,
context: Pick<Context, 'user'>,
): Promise<gql.StandardPayload> {
const { cameraId, newId } = input;
let successfulOps: OperationMetadata[] = [];
console.log('CameraModel.updateSerialNumber - input: ', input);

try {
// check if it's a wireless camera
const wirelessCamera = await WirelessCamera.findOne({ _id: cameraId });
if (wirelessCamera) {
throw new GraphQLError(`You can't update wireless cameras' serial numbers`);
}

// Step 1: update serial number on all images
// TODO: do we want to wrap this all in a retry?
console.time('update-serial-number-on-images');
const affectedImgIds = (await Images.find({ cameraId }, '_id')).map((img) =>
img._id.toString(),
);
const res = await Images.updateMany({ cameraId }, { cameraId: newId });
console.log('CameraModel.updateSerialNumber() Images.updateMany - res: ', res);
console.timeEnd('update-serial-number-on-images');
if (res.matchedCount !== res.modifiedCount) {
throw new GraphQLError('Not all images were updated successfully');
}
successfulOps.push({
op: 'images-updated',
info: { sourceCamId: input.cameraId, targetCamId: newId, affectedImgIds },
});
console.log('CameraModel.updateSerialNumber() - updated all images');

// Step 2: check if we're merging a source camera into a target camera
const project = await ProjectModel.queryById(context.user['curr_project']);
const sourceCamConfig = project.cameraConfigs.find((cc) => idMatch(cc._id, cameraId));
const isMerge = project.cameraConfigs.some((cc) => idMatch(cc._id, newId));

if (isMerge) {
console.log(
`CameraModel.updateSerialNumber() - merging cameras - source: ${cameraId}, target: ${newId}`,
);
const sourceDeployments =
sourceCamConfig?.deployments.map((dep) => dep._id.toString()) || [];

// Step 3: remove source cameraConfig from Project
console.log('CameraModel.updateSerialNumber() - removing source cameraConfig');
const ccIdx = project.cameraConfigs.findIndex((cc) => idMatch(cc._id, cameraId));
project.cameraConfigs.splice(ccIdx, 1);

// Step 4: re-map images to deployments in target cameraConfig
console.log('CameraModel.updateSerialNumber() - re-mapping images to target cameraConfig');
const targetCamConfig = project.cameraConfigs.find((cc) => idMatch(cc._id, newId));
if (!targetCamConfig) {
throw new GraphQLError(`Could not find cameraConfig for camera ${newId}`);
}
await ProjectModel.reMapImagesToDeps({ projId: project._id, camConfig: targetCamConfig });
successfulOps.push({
op: 'images-remapped-to-target-deps',
info: { sourceCamConfig },
});

// Step 5: remove source deployments from Views
console.log('CameraModel.updateSerialNumber() - removing source deployments from Views');
project.views.forEach((view) => {
console.log('CameraModel.updateSerialNumber() - checking view: ', view.name);
if (view.filters.deployments?.length) {
console.log('CameraModel.updateSerialNumber() - view has deployments');
view.filters.deployments = view.filters.deployments.filter(
(depId) => !sourceDeployments.includes(depId),
);
console.log(
'CameraModel.updateSerialNumber() - updated view.filters.deployments: ',
view.filters.deployments,
);
}
});
} else {
// Step 3: update Project's cameraConfig with new _id
console.log(
'CameraModel.updateSerialNumber() - NOT a merge. Just updating cameraConfig with new _id',
);
if (!sourceCamConfig) {
throw new GraphQLError(`Could not find cameraConfig for camera ${cameraId}`);
}
sourceCamConfig._id = newId;
}

console.log('CameraModel.updateSerialNumber() - saving Project: ', project);
await project.save();
console.log('CameraModel.updateSerialNumber() - updated Project.cameraConfigs');

return { isOk: true };
} catch (err) {
// reverse successful operations
// NOTE: many of the mutations above involve updating the Project document,
// which is the last operation and thus would be the last to fail,
// so we can safely assume that if we're in this catch block,
// the Project document has not been updated
console.log('CameraModel.updateSerialNumber() - caught error: ', err);
for (const op of successfulOps) {
if (op.op === 'images-updated') {
console.log('reversing images-updated operation');
await Images.updateMany(
{ _id: { $in: op.info.affectedImgIds } },
{ cameraId: op.info.sourceCamId },
);
} else if (op.op === 'images-remapped-to-target-deps') {
console.log('reversing images-remapped-to-target-deps operation');
// re-map images back to source cameraConfig deployments
// NOTE: the images that were already associated with the target cameraConfig
// don't need to be re-mapped because their deploymentIds should not have changed
await ProjectModel.reMapImagesToDeps({
projId: context.user['curr_project'],
camConfig: op.info.sourceCamConfig,
});
}
}

if (err instanceof GraphQLError) throw err;
throw new InternalServerError(err as string);
}
}
}

export default class AuthedCameraModel extends BaseAuthedModel {
Expand All @@ -249,9 +400,14 @@ export default class AuthedCameraModel extends BaseAuthedModel {
async unregisterCamera(...args: MethodParams<typeof CameraModel.unregisterCamera>) {
return await CameraModel.unregisterCamera(...args);
}

@roleCheck(WRITE_CAMERA_SERIAL_NUMBER_ROLES)
async updateSerialNumber(...args: MethodParams<typeof CameraModel.updateSerialNumberTask>) {
return await CameraModel.updateSerialNumberTask(...args);
}
}

interface OperationMetadata {
op: string;
info: { cameraId: string };
info: { [key: string]: any };
}
4 changes: 4 additions & 0 deletions src/api/db/models/Image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export class ImageModel {
throw new InternalServerError(err as string);
}
}

/**
* Creates an ImageAttempt record and Image record
* This is called by the image-ingestion lambda when new images are detected
Expand Down Expand Up @@ -626,6 +627,9 @@ export class ImageModel {
let projectId: string = '';

try {
// NOTE: this could probably be optimized to use a single bulkWrite operation
// (see example in createLabels() below), but it's not a high priority since
// but at most this will receive 10 labels at a time, so there's no risk of timeouts
for (const label of input.labels) {
const res = await retry(
async () => {
Expand Down
13 changes: 9 additions & 4 deletions src/api/db/models/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import * as gql from '../../../@types/graphql.js';
import { TaskSchema } from '../schemas/Task.js';

// The max number of labeled images that can be deleted
// when removin a label from a project
// when removing a label from a project
const MAX_LABEL_DELETE = 500;

const ObjectId = mongoose.Types.ObjectId;
Expand Down Expand Up @@ -353,6 +353,9 @@ export class ProjectModel {
}
}

// NOTE: this function is only called as part of CRUD ops on deployments,
// or if we are merging one camera into another in updateCameraSerialNumber,
// all of which are themselves called by the async task handler
static async reMapImagesToDeps({
projId,
camConfig,
Expand All @@ -361,6 +364,7 @@ export class ProjectModel {
camConfig: HydratedDocument<CameraConfigSchema>;
}) {
try {
console.time('reMapImagesToDeps');
await retry(
async () => {
// build array of operations from camConfig.deployments:
Expand Down Expand Up @@ -408,6 +412,7 @@ export class ProjectModel {
},
{ retries: 3 },
);
console.timeEnd('reMapImagesToDeps');
} catch (err) {
if (err instanceof GraphQLError) throw err;
throw new InternalServerError(err as string);
Expand All @@ -434,7 +439,7 @@ export class ProjectModel {
}
}

// NOTE: this function is called by the task handler
// NOTE: this function is called by the async task handler
static async createDeployment(
input: gql.CreateDeploymentInput,
context: Pick<Context, 'user'>,
Expand Down Expand Up @@ -488,7 +493,7 @@ export class ProjectModel {
}
}

// NOTE: this function is called by the task handler
// NOTE: this function is called by the async task handler
static async updateDeployment(
input: gql.UpdateDeploymentInput,
context: Pick<Context, 'user'>,
Expand Down Expand Up @@ -548,7 +553,7 @@ export class ProjectModel {
}
}

// NOTE: this function is called by the task handler
// NOTE: this function is called by the async task handler
static async deleteDeployment(
{ cameraId, deploymentId }: gql.DeleteDeploymentInput,
context: Pick<Context, 'user'>,
Expand Down
1 change: 1 addition & 0 deletions src/api/db/schemas/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const TaskSchema = new Schema({
'CreateDeployment',
'UpdateDeployment',
'DeleteDeployment',
'UpdateSerialNumber',
],
},
status: {
Expand Down
8 changes: 8 additions & 0 deletions src/api/resolvers/Mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ export default {
return context.models.Camera.unregisterCamera(input, context);
},

updateCameraSerialNumber: async (
_: unknown,
{ input }: gql.MutationUpdateCameraSerialNumberArgs,
context: Context,
): Promise<gql.Task> => {
return context.models.Camera.updateSerialNumber(input, context);
},

createProject: async (
_: unknown,
{ input }: gql.MutationCreateProjectArgs,
Expand Down
6 changes: 6 additions & 0 deletions src/api/type-defs/inputs/UpdateCameraSerialNumberInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default /* GraphQL */ `
input UpdateCameraSerialNumberInput {
cameraId: ID!
newId: String!
}
`;
1 change: 1 addition & 0 deletions src/api/type-defs/root/Mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default /* GraphQL */ `
registerCamera(input: RegisterCameraInput!): RegisterCameraPayload
unregisterCamera(input: UnregisterCameraInput!): UnregisterCameraPayload
updateCameraSerialNumber(input: UpdateCameraSerialNumberInput!): Task
createView(input: CreateViewInput!): CreateViewPayload
updateView(input: UpdateViewInput!): UpdateViewPayload
Expand Down
9 changes: 9 additions & 0 deletions src/task/camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type User } from '../api/auth/authorization.js';
import { CameraModel } from '../api/db/models/Camera.js';
import { type TaskInput } from '../api/db/models/Task.js';
import type * as gql from '../@types/graphql.js';

export async function UpdateSerialNumber(task: TaskInput<gql.UpdateCameraSerialNumberInput>) {
const context = { user: { is_superuser: true, curr_project: task.projectId } as User };
return await CameraModel.updateSerialNumber(task.config, context);
}
Loading

0 comments on commit 238eba3

Please sign in to comment.