Cleanup actions, errors, implement actionlog for stats
This commit is contained in:
@@ -90,6 +90,9 @@ func (p {{ $.Service }}ActionProps) serialize() actionlog.Meta {
|
||||
str = func(i interface{}) string { return fmt.Sprintf("%v", i) }
|
||||
)
|
||||
|
||||
// avoiding declared but not used
|
||||
_ = str
|
||||
|
||||
{{ range $prop := $.Props }}
|
||||
{{- if $prop.Builtin }}
|
||||
m["{{ $prop.Name }}"] = str(p.{{ $prop.Name }})
|
||||
|
||||
@@ -17,7 +17,7 @@ type (
|
||||
}
|
||||
|
||||
statsService interface {
|
||||
Metrics(context.Context) (interface{}, error)
|
||||
Metrics(context.Context) (*service.StatisticsMetricsPayload, error)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ func (p accessControlActionProps) serialize() actionlog.Meta {
|
||||
str = func(i interface{}) string { return fmt.Sprintf("%v", i) }
|
||||
)
|
||||
|
||||
// avoiding declared but not used
|
||||
_ = str
|
||||
|
||||
if p.rule != nil {
|
||||
m["rule.operation"] = str(p.rule.Operation)
|
||||
m["rule.roleID"] = str(p.rule.RoleID)
|
||||
|
||||
@@ -107,6 +107,9 @@ func (p applicationActionProps) serialize() actionlog.Meta {
|
||||
str = func(i interface{}) string { return fmt.Sprintf("%v", i) }
|
||||
)
|
||||
|
||||
// avoiding declared but not used
|
||||
_ = str
|
||||
|
||||
if p.application != nil {
|
||||
m["application.name"] = str(p.application.Name)
|
||||
m["application.ID"] = str(p.application.ID)
|
||||
|
||||
@@ -522,15 +522,7 @@ func (svc auth) InternalLogin(email string, password string) (u *types.User, err
|
||||
}
|
||||
|
||||
if !u.Valid() {
|
||||
if u.SuspendedAt != nil {
|
||||
err = ErrUserSuspended
|
||||
} else if u.DeletedAt != nil {
|
||||
err = ErrUserDeleted
|
||||
} else {
|
||||
err = ErrUserInvalid
|
||||
}
|
||||
u = nil
|
||||
return err
|
||||
return AuthErrFailedForDisabledUser()
|
||||
}
|
||||
|
||||
if !u.EmailConfirmed {
|
||||
|
||||
@@ -119,6 +119,9 @@ func (p authActionProps) serialize() actionlog.Meta {
|
||||
str = func(i interface{}) string { return fmt.Sprintf("%v", i) }
|
||||
)
|
||||
|
||||
// avoiding declared but not used
|
||||
_ = str
|
||||
|
||||
m["email"] = str(p.email)
|
||||
m["provider"] = str(p.provider)
|
||||
if p.credentials != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -16,10 +17,8 @@ import (
|
||||
|
||||
type (
|
||||
authNotification struct {
|
||||
ctx context.Context
|
||||
logger *zap.Logger
|
||||
|
||||
// @todo merge auth & system settings
|
||||
ctx context.Context
|
||||
logger *zap.Logger
|
||||
settings *types.Settings
|
||||
}
|
||||
|
||||
@@ -105,7 +104,7 @@ func (svc authNotification) send(name, lang string, payload authNotificationPayl
|
||||
ntf.SetBody("text/html", svc.render(svc.settings.Auth.Mail.PasswordReset.Body, payload))
|
||||
|
||||
default:
|
||||
return ErrNoEmailTemplateForGivenOperation
|
||||
return fmt.Errorf("unknown notification email template %q", name)
|
||||
}
|
||||
|
||||
svc.log(svc.ctx).Debug(
|
||||
|
||||
+2
-17
@@ -9,23 +9,8 @@ type (
|
||||
)
|
||||
|
||||
const (
|
||||
ErrInvalidID serviceError = "InvalidID"
|
||||
ErrInvalidHandle serviceError = "InvalidHandle"
|
||||
ErrNoPermissions serviceError = "NoPermissions"
|
||||
ErrNoGrantPermissions serviceError = "NoGrantPermissions"
|
||||
ErrNoCreatePermissions serviceError = "NoCreatePermissions"
|
||||
ErrNoUpdatePermissions serviceError = "NoUpdatePermissions"
|
||||
ErrNoDeletePermissions serviceError = "NoDeletePermissions"
|
||||
ErrNoReadPermissions serviceError = "NoReadPermissions"
|
||||
ErrNoTriggerManagementPermissions serviceError = "NoTriggerManagementPermissions"
|
||||
ErrNoScriptCreatePermissions serviceError = "NoScriptCreatePermissions"
|
||||
ErrNoReminderAssignPermissions serviceError = "NoReminderAssignPermissions"
|
||||
|
||||
ErrUserSuspended serviceError = "UserSuspended"
|
||||
ErrUserDeleted serviceError = "UserDeleted"
|
||||
ErrUserInvalid serviceError = "UserInvalid"
|
||||
|
||||
ErrNoEmailTemplateForGivenOperation serviceError = "NoEmailTemplateForGivenOperation"
|
||||
ErrNoUpdatePermissions serviceError = "NoUpdatePermissions"
|
||||
ErrNoReminderAssignPermissions serviceError = "NoReminderAssignPermissions"
|
||||
)
|
||||
|
||||
func (e serviceError) Error() string {
|
||||
|
||||
@@ -143,6 +143,9 @@ func (p roleActionProps) serialize() actionlog.Meta {
|
||||
str = func(i interface{}) string { return fmt.Sprintf("%v", i) }
|
||||
)
|
||||
|
||||
// avoiding declared but not used
|
||||
_ = str
|
||||
|
||||
if p.member != nil {
|
||||
m["member.handle"] = str(p.member.Handle)
|
||||
m["member.email"] = str(p.member.Email)
|
||||
|
||||
@@ -158,7 +158,7 @@ func Initialize(ctx context.Context, log *zap.Logger, c Config) (err error) {
|
||||
DefaultApplication = Application(ctx)
|
||||
DefaultReminder = Reminder(ctx)
|
||||
DefaultSink = Sink()
|
||||
DefaultStatistics = Statistics(ctx)
|
||||
DefaultStatistics = Statistics()
|
||||
DefaultAttachment = Attachment(DefaultStore)
|
||||
|
||||
return
|
||||
|
||||
@@ -122,6 +122,9 @@ func (p sinkActionProps) serialize() actionlog.Meta {
|
||||
str = func(i interface{}) string { return fmt.Sprintf("%v", i) }
|
||||
)
|
||||
|
||||
// avoiding declared but not used
|
||||
_ = str
|
||||
|
||||
m["url"] = str(p.url)
|
||||
m["responseStatus"] = str(p.responseStatus)
|
||||
m["contentType"] = str(p.contentType)
|
||||
|
||||
@@ -3,71 +3,59 @@ package service
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/titpetric/factory"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza-server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza-server/system/repository"
|
||||
"github.com/cortezaproject/corteza-server/system/types"
|
||||
)
|
||||
|
||||
type (
|
||||
statistics struct {
|
||||
db *factory.DB
|
||||
ctx context.Context
|
||||
logger *zap.Logger
|
||||
|
||||
ac statisticsAccessController
|
||||
actionlog actionlog.Recorder
|
||||
ac statisticsAccessController
|
||||
}
|
||||
|
||||
statisticsAccessController interface {
|
||||
CanAccess(context.Context) bool
|
||||
}
|
||||
|
||||
StatisticsMetricsPayload struct {
|
||||
Users *types.UserMetrics `json:"users"`
|
||||
Roles *types.RoleMetrics `json:"roles"`
|
||||
Applications *types.ApplicationMetrics `json:"applications"`
|
||||
}
|
||||
)
|
||||
|
||||
func Statistics(ctx context.Context) *statistics {
|
||||
func Statistics() *statistics {
|
||||
return &statistics{
|
||||
db: repository.DB(ctx),
|
||||
ac: DefaultAccessControl,
|
||||
logger: DefaultLogger.Named("statistics"),
|
||||
actionlog: DefaultActionlog,
|
||||
ac: DefaultAccessControl,
|
||||
}
|
||||
}
|
||||
|
||||
// log() returns zap's logger with requestID from current context and fields.
|
||||
func (svc statistics) log(ctx context.Context, fields ...zapcore.Field) *zap.Logger {
|
||||
return logger.AddRequestID(ctx, svc.logger).With(fields...)
|
||||
}
|
||||
func (svc statistics) Metrics(ctx context.Context) (rval *StatisticsMetricsPayload, err error) {
|
||||
db := repository.DB(ctx)
|
||||
|
||||
func (svc statistics) Metrics(ctx context.Context) (interface{}, error) {
|
||||
if !svc.ac.CanAccess(ctx) {
|
||||
return nil, ErrNoPermissions
|
||||
}
|
||||
|
||||
type (
|
||||
metricsPayload struct {
|
||||
Users *types.UserMetrics `json:"users"`
|
||||
Roles *types.RoleMetrics `json:"roles"`
|
||||
Applications *types.ApplicationMetrics `json:"applications"`
|
||||
err = db.Transaction(func() error {
|
||||
if !svc.ac.CanAccess(ctx) {
|
||||
return StatisticsErrNotAllowedToReadStatistics()
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
rval = &metricsPayload{}
|
||||
err error
|
||||
)
|
||||
rval = &StatisticsMetricsPayload{}
|
||||
|
||||
if rval.Users, err = repository.User(ctx, svc.db).Metrics(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rval.Users, err = repository.User(ctx, db).Metrics(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rval.Roles, err = repository.Role(ctx, svc.db).Metrics(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rval.Roles, err = repository.Role(ctx, db).Metrics(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rval.Applications, err = repository.Application(ctx, svc.db).Metrics(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rval.Applications, err = repository.Application(ctx, db).Metrics(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rval, err
|
||||
return nil
|
||||
})
|
||||
|
||||
return rval, svc.recordAction(ctx, &statisticsActionProps{}, StatisticsActionServe, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
package service
|
||||
|
||||
// This file is auto-generated from system/service/statistics_actions.yaml
|
||||
//
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/pkg/actionlog"
|
||||
)
|
||||
|
||||
type (
|
||||
statisticsActionProps struct {
|
||||
}
|
||||
|
||||
statisticsAction struct {
|
||||
timestamp time.Time
|
||||
resource string
|
||||
action string
|
||||
log string
|
||||
severity actionlog.Severity
|
||||
|
||||
// prefix for error when action fails
|
||||
errorMessage string
|
||||
|
||||
props *statisticsActionProps
|
||||
}
|
||||
|
||||
statisticsError struct {
|
||||
timestamp time.Time
|
||||
error string
|
||||
resource string
|
||||
action string
|
||||
message string
|
||||
log string
|
||||
severity actionlog.Severity
|
||||
|
||||
wrap error
|
||||
|
||||
props *statisticsActionProps
|
||||
}
|
||||
)
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Props methods
|
||||
|
||||
// serialize converts statisticsActionProps to actionlog.Meta
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (p statisticsActionProps) serialize() actionlog.Meta {
|
||||
var (
|
||||
m = make(actionlog.Meta)
|
||||
str = func(i interface{}) string { return fmt.Sprintf("%v", i) }
|
||||
)
|
||||
|
||||
// avoiding declared but not used
|
||||
_ = str
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// tr translates string and replaces meta value placeholder with values
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (p statisticsActionProps) tr(in string, err error) string {
|
||||
var pairs = []string{"{err}"}
|
||||
|
||||
if err != nil {
|
||||
for {
|
||||
// Unwrap errors
|
||||
ue := errors.Unwrap(err)
|
||||
if ue == nil {
|
||||
break
|
||||
}
|
||||
|
||||
err = ue
|
||||
}
|
||||
|
||||
pairs = append(pairs, err.Error())
|
||||
} else {
|
||||
pairs = append(pairs, "nil")
|
||||
}
|
||||
return strings.NewReplacer(pairs...).Replace(in)
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action methods
|
||||
|
||||
// String returns loggable description as string
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (a *statisticsAction) String() string {
|
||||
var props = &statisticsActionProps{}
|
||||
|
||||
if a.props != nil {
|
||||
props = a.props
|
||||
}
|
||||
|
||||
return props.tr(a.log, nil)
|
||||
}
|
||||
|
||||
func (e *statisticsAction) LoggableAction() *actionlog.Action {
|
||||
return &actionlog.Action{
|
||||
Timestamp: e.timestamp,
|
||||
Resource: e.resource,
|
||||
Action: e.action,
|
||||
Severity: e.severity,
|
||||
Description: e.String(),
|
||||
Meta: e.props.serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Error methods
|
||||
|
||||
// String returns loggable description as string
|
||||
//
|
||||
// It falls back to message if log is not set
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (e *statisticsError) String() string {
|
||||
var props = &statisticsActionProps{}
|
||||
|
||||
if e.props != nil {
|
||||
props = e.props
|
||||
}
|
||||
|
||||
if e.wrap != nil && !strings.Contains(e.log, "{err}") {
|
||||
// Suffix error log with {err} to ensure
|
||||
// we log the cause for this error
|
||||
e.log += ": {err}"
|
||||
}
|
||||
|
||||
return props.tr(e.log, e.wrap)
|
||||
}
|
||||
|
||||
// Error satisfies
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (e *statisticsError) Error() string {
|
||||
var props = &statisticsActionProps{}
|
||||
|
||||
if e.props != nil {
|
||||
props = e.props
|
||||
}
|
||||
|
||||
return props.tr(e.message, e.wrap)
|
||||
}
|
||||
|
||||
// Is fn for error equality check
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (e *statisticsError) Is(Resource error) bool {
|
||||
t, ok := Resource.(*statisticsError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return t.resource == e.resource && t.error == e.error
|
||||
}
|
||||
|
||||
// Wrap wraps statisticsError around another error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (e *statisticsError) Wrap(err error) *statisticsError {
|
||||
e.wrap = err
|
||||
return e
|
||||
}
|
||||
|
||||
// Unwrap returns wrapped error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (e *statisticsError) Unwrap() error {
|
||||
return e.wrap
|
||||
}
|
||||
|
||||
func (e *statisticsError) LoggableAction() *actionlog.Action {
|
||||
return &actionlog.Action{
|
||||
Timestamp: e.timestamp,
|
||||
Resource: e.resource,
|
||||
Action: e.action,
|
||||
Severity: e.severity,
|
||||
Description: e.String(),
|
||||
Error: e.Error(),
|
||||
Meta: e.props.serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action constructors
|
||||
|
||||
// StatisticsActionServe returns "system:statistics.serve" error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func StatisticsActionServe(props ...*statisticsActionProps) *statisticsAction {
|
||||
a := &statisticsAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "system:statistics",
|
||||
action: "serve",
|
||||
log: "metrics served",
|
||||
severity: actionlog.Debug,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Error constructors
|
||||
|
||||
// StatisticsErrGeneric returns "system:statistics.generic" audit event as actionlog.Error
|
||||
//
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func StatisticsErrGeneric(props ...*statisticsActionProps) *statisticsError {
|
||||
var e = &statisticsError{
|
||||
timestamp: time.Now(),
|
||||
resource: "system:statistics",
|
||||
error: "generic",
|
||||
action: "error",
|
||||
message: "failed to complete request due to internal error",
|
||||
log: "{err}",
|
||||
severity: actionlog.Error,
|
||||
props: func() *statisticsActionProps {
|
||||
if len(props) > 0 {
|
||||
return props[0]
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
e.props = props[0]
|
||||
}
|
||||
|
||||
return e
|
||||
|
||||
}
|
||||
|
||||
// StatisticsErrNotAllowedToReadStatistics returns "system:statistics.notAllowedToReadStatistics" audit event as actionlog.Warning
|
||||
//
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func StatisticsErrNotAllowedToReadStatistics(props ...*statisticsActionProps) *statisticsError {
|
||||
var e = &statisticsError{
|
||||
timestamp: time.Now(),
|
||||
resource: "system:statistics",
|
||||
error: "notAllowedToReadStatistics",
|
||||
action: "error",
|
||||
message: "not allowed to read statistics",
|
||||
log: "not allowed to read statistics",
|
||||
severity: actionlog.Warning,
|
||||
props: func() *statisticsActionProps {
|
||||
if len(props) > 0 {
|
||||
return props[0]
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
e.props = props[0]
|
||||
}
|
||||
|
||||
return e
|
||||
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
|
||||
// recordAction is a service helper function wraps function that can return error
|
||||
//
|
||||
// context is used to enrich audit log entry with current user info, request ID, IP address...
|
||||
// props are collected action/error properties
|
||||
// action (optional) fn will be used to construct statisticsAction struct from given props (and error)
|
||||
// err is any error that occurred while action was happening
|
||||
//
|
||||
// Action has success and fail (error) state:
|
||||
// - when recorded without an error (4th param), action is recorded as successful.
|
||||
// - when an additional error is given (4th param), action is used to wrap
|
||||
// the additional error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (svc statistics) recordAction(ctx context.Context, props *statisticsActionProps, action func(...*statisticsActionProps) *statisticsAction, err error) error {
|
||||
var (
|
||||
ok bool
|
||||
|
||||
// Return error
|
||||
retError *statisticsError
|
||||
|
||||
// Recorder error
|
||||
recError *statisticsError
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if retError, ok = err.(*statisticsError); !ok {
|
||||
// got non-statistics error, wrap it with StatisticsErrGeneric
|
||||
retError = StatisticsErrGeneric(props).Wrap(err)
|
||||
|
||||
// copy action to returning and recording error
|
||||
retError.action = action().action
|
||||
|
||||
// we'll use StatisticsErrGeneric for recording too
|
||||
// because it can hold more info
|
||||
recError = retError
|
||||
} else if retError != nil {
|
||||
// copy action to returning and recording error
|
||||
retError.action = action().action
|
||||
// start with copy of return error for recording
|
||||
// this will be updated with tha root cause as we try and
|
||||
// unwrap the error
|
||||
recError = retError
|
||||
|
||||
// find the original recError for this error
|
||||
// for the purpose of logging
|
||||
var unwrappedError error = retError
|
||||
for {
|
||||
if unwrappedError = errors.Unwrap(unwrappedError); unwrappedError == nil {
|
||||
// nothing wrapped
|
||||
break
|
||||
}
|
||||
|
||||
// update recError ONLY of wrapped error is of type statisticsError
|
||||
if unwrappedSinkError, ok := unwrappedError.(*statisticsError); ok {
|
||||
recError = unwrappedSinkError
|
||||
}
|
||||
}
|
||||
|
||||
if retError.props == nil {
|
||||
// set props on returning error if empty
|
||||
retError.props = props
|
||||
}
|
||||
|
||||
if recError.props == nil {
|
||||
// set props on recording error if empty
|
||||
recError.props = props
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if svc.actionlog != nil {
|
||||
if retError != nil {
|
||||
// failed action, log error
|
||||
svc.actionlog.Record(ctx, recError)
|
||||
} else if action != nil {
|
||||
// successful
|
||||
svc.actionlog.Record(ctx, action(props))
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// retError not an interface and that WILL (!!) cause issues
|
||||
// with nil check (== nil) when it is not explicitly returned
|
||||
return nil
|
||||
}
|
||||
|
||||
return retError
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# List of security/audit events and errors that we need to log
|
||||
|
||||
resource: system:statistics
|
||||
service: statistics
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: debug
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: error
|
||||
|
||||
actions:
|
||||
- action: serve
|
||||
log: "metrics served"
|
||||
|
||||
errors:
|
||||
- error: notAllowedToReadStatistics
|
||||
message: "not allowed to read statistics"
|
||||
severity: warning
|
||||
@@ -348,7 +348,7 @@ func (svc user) Update(upd *types.User) (u *types.User, err error) {
|
||||
|
||||
err = svc.db.Transaction(func() (err error) {
|
||||
if upd.ID == 0 {
|
||||
return ErrInvalidID
|
||||
return UserErrInvalidID()
|
||||
}
|
||||
|
||||
if !handle.IsValid(upd.Handle) {
|
||||
|
||||
@@ -119,6 +119,9 @@ func (p userActionProps) serialize() actionlog.Meta {
|
||||
str = func(i interface{}) string { return fmt.Sprintf("%v", i) }
|
||||
)
|
||||
|
||||
// avoiding declared but not used
|
||||
_ = str
|
||||
|
||||
if p.user != nil {
|
||||
m["user.handle"] = str(p.user.Handle)
|
||||
m["user.email"] = str(p.user.Email)
|
||||
|
||||
Reference in New Issue
Block a user