mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-19 18:29:23 +00:00
[api & notification] Add send_file_updates_email command and update account_info API
This commit is contained in:
@@ -289,7 +289,7 @@ class AccountInfo(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
throttle_classes = (UserRateThrottle, )
|
||||
|
||||
def get(self, request, format=None):
|
||||
def _get_account_info(self, request):
|
||||
info = {}
|
||||
email = request.user.username
|
||||
p = Profile.objects.get_profile_by_user(email)
|
||||
@@ -321,7 +321,54 @@ class AccountInfo(APIView):
|
||||
info['institution'] = p.institution if p and p.institution else ""
|
||||
info['is_staff'] = request.user.is_staff
|
||||
|
||||
return Response(info)
|
||||
interval = UserOptions.objects.get_file_updates_email_interval(email)
|
||||
info['email_notification_interval'] = 0 if interval is None else interval
|
||||
return info
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response(self._get_account_info(request))
|
||||
|
||||
def put(self, request, format=None):
|
||||
"""Update account info.
|
||||
"""
|
||||
username = request.user.username
|
||||
|
||||
name = request.data.get("name", None)
|
||||
if name is not None:
|
||||
if len(name) > 64:
|
||||
return api_error(status.HTTP_400_BAD_REQUEST,
|
||||
_(u'Name is too long (maximum is 64 characters)'))
|
||||
|
||||
if "/" in name:
|
||||
return api_error(status.HTTP_400_BAD_REQUEST,
|
||||
_(u"Name should not include '/'."))
|
||||
|
||||
email_interval = request.data.get("email_notification_interval", None)
|
||||
if email_interval is not None:
|
||||
try:
|
||||
interval = int(email_interval)
|
||||
except ValueError:
|
||||
return api_error(status.HTTP_400_BAD_REQUEST,
|
||||
'email_interval invalid')
|
||||
|
||||
# update user info
|
||||
|
||||
if name is not None:
|
||||
profile = Profile.objects.get_profile_by_user(username)
|
||||
if profile is None:
|
||||
profile = Profile(user=username)
|
||||
profile.nickname = name
|
||||
profile.save()
|
||||
|
||||
if interval is not None:
|
||||
if interval <= 0:
|
||||
UserOptions.objects.unset_file_updates_email_interval(username)
|
||||
else:
|
||||
UserOptions.objects.set_file_updates_email_interval(
|
||||
username, email_interval)
|
||||
|
||||
return Response(self._get_account_info(request))
|
||||
|
||||
|
||||
class RegDevice(APIView):
|
||||
"""Reg device for iOS push notification.
|
||||
|
@@ -116,9 +116,9 @@ else:
|
||||
repo_owner = kwargs['repo_owner']
|
||||
|
||||
if org_id > 0:
|
||||
related_users = [r.user for r in seafile_api.org_get_shared_users_by_repo(org_id, repo_id)]
|
||||
related_users = seafile_api.org_get_shared_users_by_repo(org_id, repo_id)
|
||||
else:
|
||||
related_users = [r.user for r in seafile_api.get_shared_users_by_repo(repo_id)]
|
||||
related_users = seafile_api.get_shared_users_by_repo(repo_id)
|
||||
org_id = -1
|
||||
|
||||
related_users.append(repo_owner)
|
||||
|
272
seahub/notifications/management/commands/send_file_updates.py
Normal file
272
seahub/notifications/management/commands/send_file_updates.py
Normal file
@@ -0,0 +1,272 @@
|
||||
# Copyright (c) 2012-2016 Seafile Ltd.
|
||||
# encoding: utf-8
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.html import escape as e
|
||||
from django.utils import translation
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from seahub.avatar.templatetags.avatar_tags import avatar
|
||||
from seahub.avatar.util import get_default_avatar_url
|
||||
from seahub.base.templatetags.seahub_tags import email2nickname
|
||||
from seahub.constants import HASH_URLS
|
||||
from seahub.options.models import (
|
||||
UserOptions, KEY_FILE_UPDATES_EMAIL_INTERVAL,
|
||||
KEY_FILE_UPDATES_LAST_EMAILED_TIME
|
||||
)
|
||||
from seahub.profile.models import Profile
|
||||
from seahub.utils import (get_site_name, seafevents_api,
|
||||
send_html_email, get_site_scheme_and_netloc)
|
||||
from seahub.utils.timeutils import utc_to_local
|
||||
|
||||
# Get an instance of a logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
########## Utility Functions ##########
|
||||
def td(con):
|
||||
return con
|
||||
# return '<td>%s</td>' % con
|
||||
|
||||
def a_tag(con, href='#'):
|
||||
return '<a href="%s">%s</a>' % (href, e(con))
|
||||
|
||||
def repo_url(repo_id):
|
||||
p = HASH_URLS["VIEW_COMMON_LIB_DIR"] % {'repo_id': repo_id, 'path': ''}
|
||||
return get_site_scheme_and_netloc() + p
|
||||
|
||||
def file_url(repo_id, file_path):
|
||||
p = reverse('view_lib_file', args=[repo_id, file_path])
|
||||
return get_site_scheme_and_netloc() + p
|
||||
|
||||
def dir_url(repo_id, dir_path):
|
||||
p = HASH_URLS["VIEW_COMMON_LIB_DIR"] % {
|
||||
'repo_id': repo_id, 'path': dir_path.strip('/')
|
||||
}
|
||||
return get_site_scheme_and_netloc() + p
|
||||
|
||||
def user_info_url(username):
|
||||
p = reverse('user_profile', args=[username])
|
||||
return get_site_scheme_and_netloc() + p
|
||||
|
||||
#######################################
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Send Email notifications to user if he/she has '
|
||||
'file updates notices every period of seconds .'
|
||||
label = "notifications_send_file_updates"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
logger.debug('Start sending file updates emails...')
|
||||
self.do_action()
|
||||
logger.debug('Finish sending file updates emails.\n')
|
||||
|
||||
def get_avatar(self, username, default_size=32):
|
||||
img_tag = avatar(username, default_size)
|
||||
pattern = r'src="(.*)"'
|
||||
repl = r'src="%s\1"' % get_site_scheme_and_netloc()
|
||||
return re.sub(pattern, repl, img_tag)
|
||||
|
||||
def get_avatar_src(self, username, default_size=32):
|
||||
avatar_img = self.get_avatar(username, default_size)
|
||||
m = re.search('<img src="(.*?)".*', avatar_img)
|
||||
if m:
|
||||
return m.group(1)
|
||||
else:
|
||||
return ''
|
||||
|
||||
def get_default_avatar(self, default_size=32):
|
||||
# user default avatar
|
||||
img_tag = """<img src="%s" width="%s" height="%s" class="avatar" alt="" />""" % \
|
||||
(get_default_avatar_url(), default_size, default_size)
|
||||
pattern = r'src="(.*)"'
|
||||
repl = r'src="%s\1"' % get_site_scheme_and_netloc()
|
||||
return re.sub(pattern, repl, img_tag)
|
||||
|
||||
def get_default_avatar_src(self, default_size=32):
|
||||
avatar_img = self.get_default_avatar(default_size)
|
||||
m = re.search('<img src="(.*?)".*', avatar_img)
|
||||
if m:
|
||||
return m.group(1)
|
||||
else:
|
||||
return ''
|
||||
|
||||
def get_user_language(self, username):
|
||||
return Profile.objects.get_user_language(username)
|
||||
|
||||
def format_file_operation(self, ev):
|
||||
lib_link = a_tag(ev.repo_name, repo_url(ev.repo_id))
|
||||
if ev.obj_type == 'repo':
|
||||
if ev.op_type == 'create':
|
||||
op = _('Created library')
|
||||
details = td(lib_link)
|
||||
elif ev.op_type == 'rename':
|
||||
op = _('Renamed library')
|
||||
details = td('%s => %s' % (e(ev.old_repo_name), lib_link))
|
||||
elif ev.op_type == 'delete':
|
||||
op = _('Deleted library')
|
||||
details = td(e(ev.repo_name))
|
||||
elif ev.op_type == 'recover':
|
||||
op = _('Restored library')
|
||||
details = td(lib_link)
|
||||
else: # ev.op_type == 'clean-up-trash':
|
||||
if ev.days == 0:
|
||||
op = _('Removed all items from trash.')
|
||||
else:
|
||||
op = _('Removed items older than %s days from trash.' %
|
||||
ev.days)
|
||||
details = td(lib_link)
|
||||
|
||||
elif ev.obj_type == 'file':
|
||||
file_name = os.path.basename(ev.path)
|
||||
file_link = a_tag(file_name, file_url(ev.repo_id, ev.path))
|
||||
if ev.op_type == 'create':
|
||||
op = _('Created file')
|
||||
details = td("%s<br />%s" % (file_link, lib_link))
|
||||
elif ev.op_type == 'delete':
|
||||
op = _('Deleted file')
|
||||
details = td("%s<br />%s" % (e(file_name), lib_link))
|
||||
elif ev.op_type == 'recover':
|
||||
op = _('Restored file')
|
||||
details = td("%s<br />%s" % (file_link, lib_link))
|
||||
elif ev.op_type == 'rename':
|
||||
op = _('Renamed file')
|
||||
old_name = os.path.basename(ev.old_path)
|
||||
details = td("%s => %s<br />%s" % (
|
||||
e(old_name), file_link, lib_link)
|
||||
)
|
||||
elif ev.op_type == 'move':
|
||||
op = _('Moved file')
|
||||
file_path_link = a_tag(ev.path, file_url(ev.repo_id, ev.path))
|
||||
details = td('%s => %s<br />%s' % (
|
||||
e(ev.old_path), file_path_link, lib_link)
|
||||
)
|
||||
else: # ev.op_type == 'edit':
|
||||
op = _('Updated file')
|
||||
details = td("%s<br />%s" % (file_link, lib_link))
|
||||
|
||||
else: # dir
|
||||
dir_name = os.path.basename(ev.path)
|
||||
dir_link = a_tag(dir_name, dir_url(ev.repo_id, ev.path))
|
||||
if ev.op_type == 'create':
|
||||
op = _('Created folder')
|
||||
details = td('%s<br />%s' % (dir_link, lib_link))
|
||||
elif ev.op_type == 'delete':
|
||||
op = _('Deleted folder')
|
||||
details = td('%s<br />%s' % (e(dir_name), lib_link))
|
||||
elif ev.op_type == 'recover':
|
||||
op = _('Restored folder')
|
||||
details = td('%s<br />%s' % (dir_link, lib_link))
|
||||
elif ev.op_type == 'rename':
|
||||
op = _('Renamed folder')
|
||||
old_name = os.path.basename(ev.old_path)
|
||||
details = td('%s => %s<br />%s' % (e(old_name), dir_link,
|
||||
lib_link))
|
||||
else: # ev.op_type == 'move':
|
||||
op = _('Moved folder')
|
||||
details = td('%s => %s<br />%s' % (e(ev.old_path), dir_link,
|
||||
lib_link))
|
||||
|
||||
return (op, details)
|
||||
|
||||
def do_action(self):
|
||||
today = datetime.utcnow().replace(hour=0).replace(minute=0).replace(
|
||||
second=0).replace(microsecond=0)
|
||||
|
||||
emails = []
|
||||
user_file_updates_email_intervals = []
|
||||
for ele in UserOptions.objects.filter(
|
||||
option_key=KEY_FILE_UPDATES_EMAIL_INTERVAL):
|
||||
try:
|
||||
user_file_updates_email_intervals.append(
|
||||
(ele.email, int(ele.option_val))
|
||||
)
|
||||
emails.append(ele.email)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
continue
|
||||
|
||||
user_last_emailed_time_dict = {}
|
||||
for ele in UserOptions.objects.filter(
|
||||
option_key=KEY_FILE_UPDATES_LAST_EMAILED_TIME).filter(
|
||||
email__in=emails):
|
||||
try:
|
||||
user_last_emailed_time_dict[ele.email] = datetime.strptime(
|
||||
ele.option_val, "%Y-%m-%d %H:%M:%S")
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
continue
|
||||
|
||||
for (username, interval_val) in user_file_updates_email_intervals:
|
||||
# save current language
|
||||
cur_language = translation.get_language()
|
||||
|
||||
# get and active user language
|
||||
user_language = self.get_user_language(username)
|
||||
translation.activate(user_language)
|
||||
logger.debug('Set language code to %s for user: %s' % (
|
||||
user_language, username))
|
||||
self.stdout.write('[%s] Set language code to %s' % (
|
||||
str(datetime.now()), user_language))
|
||||
|
||||
# get last_emailed_time if any, defaults to today
|
||||
last_emailed_time = user_last_emailed_time_dict.get(username, today)
|
||||
now = datetime.utcnow().replace(microsecond=0)
|
||||
if (now - last_emailed_time).seconds < interval_val:
|
||||
continue
|
||||
|
||||
# get file updates(from: last_emailed_time, to: now) for repos
|
||||
# user can access
|
||||
res = seafevents_api.get_user_activities_by_timestamp(
|
||||
username, last_emailed_time, now)
|
||||
if not res:
|
||||
continue
|
||||
|
||||
# remove my activities
|
||||
res = filter(lambda x: x.op_user != username, res)
|
||||
if not res:
|
||||
continue
|
||||
|
||||
# format mail content & send file updates email to user
|
||||
try:
|
||||
for ele in res:
|
||||
ele.user_avatar = self.get_avatar_src(ele.op_user)
|
||||
ele.local_timestamp = utc_to_local(ele.timestamp)
|
||||
ele.op_user_link = a_tag(email2nickname(ele.op_user),
|
||||
user_info_url(ele.op_user))
|
||||
ele.operation, ele.op_details = self.format_file_operation(ele)
|
||||
except Exception as e:
|
||||
logger.error('Failed to format mail content for user: %s' %
|
||||
username)
|
||||
logger.error(e, exc_info=True)
|
||||
continue
|
||||
|
||||
nickname = email2nickname(username)
|
||||
contact_email = Profile.objects.get_contact_email_by_user(username)
|
||||
|
||||
c = {
|
||||
'name': nickname,
|
||||
'updates_count': len(res),
|
||||
'updates': res,
|
||||
}
|
||||
|
||||
try:
|
||||
send_html_email(_('New file updates on %s') % get_site_name(),
|
||||
'notifications/file_updates_email.html', c,
|
||||
None, [contact_email])
|
||||
# set new last_emailed_time
|
||||
UserOptions.objects.set_file_updates_last_emailed_time(
|
||||
username, now)
|
||||
except Exception as e:
|
||||
logger.error('Failed to send email to %s, error detail: %s' %
|
||||
(contact_email, e))
|
||||
self.stderr.write('[%s] Failed to send email to %s, error '
|
||||
'detail: %s' % (str(now), contact_email, e))
|
||||
finally:
|
||||
# reset lang
|
||||
translation.activate(cur_language)
|
@@ -0,0 +1,49 @@
|
||||
{% extends 'email_base.html' %}
|
||||
|
||||
{% load i18n seahub_tags %}
|
||||
|
||||
{% block email_con %}
|
||||
|
||||
<p style="font-size:14px; line-height: 1.5; color:#121214; margin:.2em 0 12px;">{% trans "Hi," %} {{ name }}</p>
|
||||
<p style="font-size:14px; line-height: 1.5; color:#434144; margin:.2em 0;">
|
||||
{% blocktrans count num=updates_count %}
|
||||
You've got 1 new file updates on {{ site_name }}:
|
||||
{% plural %}
|
||||
You've got {{num}} file updates on {{ site_name }}:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<table style="width:100%; margin:12px 0 20px; table-layout:fixed; border-spacing: 0; border-collapse: collapse;">
|
||||
<tr>
|
||||
<th width="9%" style="padding: 5px 3px; border-bottom: 1px solid #eee;"></th>
|
||||
<th width="19%" style="padding: 5px 3px; border-bottom: 1px solid #eee; font-size:13px; text-align: left; font-weight: normal; color: #9c9c9c;">{% trans "User" %}</th>
|
||||
<th width="20%" style="padding: 5px 3px; border-bottom: 1px solid #eee; font-size:13px; text-align: left; font-weight: normal; color: #9c9c9c;">{% trans "Operation" %}</th>
|
||||
<th width="30%" style="padding: 5px 3px; border-bottom: 1px solid #eee; font-size:13px; text-align: left; font-weight: normal; color: #9c9c9c;">{% trans "File" %}</th>
|
||||
<th width="22%" style="padding: 5px 3px; border-bottom: 1px solid #eee; font-size:13px; text-align: left; font-weight: normal; color: #9c9c9c;">{% trans "Time" %}</th>
|
||||
</tr>
|
||||
|
||||
{% autoescape off %}
|
||||
{% for ele in updates %}
|
||||
<tr>
|
||||
<td style="padding:8px 3px 5px; border-bottom: 1px solid #eee; text-align:center; vertical-align:top;"><img src="{{ele.user_avatar}}" width="32" height="32" alt="" style="border-radius:1000px;" /></td>
|
||||
|
||||
<td style="padding: 5px 3px; border-bottom: 1px solid #eee; font-size: 13px; color: #333; word-wrap: break-word;">
|
||||
{{ele.op_user_link}}
|
||||
</td>
|
||||
|
||||
<td style="padding: 5px 3px; border-bottom: 1px solid #eee; font-size: 13px; color: #333; word-wrap: break-word;">
|
||||
{{ele.operation}}
|
||||
</td>
|
||||
|
||||
<td style="padding: 5px 3px; border-bottom: 1px solid #eee; font-size: 13px; color: #333; word-wrap: break-word;">
|
||||
{{ele.op_details}}
|
||||
</td>
|
||||
|
||||
<td style="padding: 5px 3px; border-bottom: 1px solid #eee; font-size: 13px; color: #333; word-wrap: break-word;">{{ ele.local_timestamp|date:"Y-m-d G:i:s"}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endautoescape %}
|
||||
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
20
seahub/options/migrations/0002_auto_20181107_0811.py
Normal file
20
seahub/options/migrations/0002_auto_20181107_0811.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.15 on 2018-11-07 08:11
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('options', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='useroptions',
|
||||
name='option_key',
|
||||
field=models.CharField(db_index=True, max_length=50),
|
||||
),
|
||||
]
|
@@ -1,11 +1,16 @@
|
||||
# Copyright (c) 2012-2016 Seafile Ltd.
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
|
||||
from seahub.base.fields import LowerCaseCharField
|
||||
from seahub.utils import is_pro_version
|
||||
|
||||
# Get an instance of a logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEY_SERVER_CRYPTO = "server_crypto"
|
||||
VAL_SERVER_CRYPTO_ENABLED = "1"
|
||||
VAL_SERVER_CRYPTO_DISABLED = "0"
|
||||
@@ -29,6 +34,9 @@ VAL_USER_LOGGED_IN = "1"
|
||||
|
||||
KEY_DEFAULT_REPO = "default_repo"
|
||||
KEY_WEBDAV_SECRET = "webdav_secret"
|
||||
KEY_FILE_UPDATES_EMAIL_INTERVAL = "file_updates_email_interval"
|
||||
KEY_FILE_UPDATES_LAST_EMAILED_TIME = "file_updates_last_emailed_time"
|
||||
|
||||
|
||||
class CryptoOptionNotSetError(Exception):
|
||||
pass
|
||||
@@ -53,6 +61,20 @@ class UserOptionsManager(models.Manager):
|
||||
|
||||
return user_option
|
||||
|
||||
def get_user_option(self, username, k):
|
||||
user_options = super(UserOptionsManager, self).filter(
|
||||
email=username, option_key=k)
|
||||
|
||||
if len(user_options) == 0:
|
||||
return None
|
||||
elif len(user_options) == 1:
|
||||
return user_options[0].option_val
|
||||
else:
|
||||
for o in user_options[1: len(user_options)]:
|
||||
o.delete()
|
||||
|
||||
return user_options[0].option_val
|
||||
|
||||
def unset_user_option(self, username, k):
|
||||
"""Remove user's option.
|
||||
"""
|
||||
@@ -177,18 +199,7 @@ class UserOptionsManager(models.Manager):
|
||||
- `self`:
|
||||
- `username`:
|
||||
"""
|
||||
user_options = super(UserOptionsManager, self).filter(
|
||||
email=username, option_key=KEY_DEFAULT_REPO)
|
||||
|
||||
if len(user_options) == 0:
|
||||
return None
|
||||
elif len(user_options) == 1:
|
||||
return user_options[0].option_val
|
||||
else:
|
||||
for o in user_options[1: len(user_options)]:
|
||||
o.delete()
|
||||
|
||||
return user_options[0].option_val
|
||||
return self.get_user_option(username, KEY_DEFAULT_REPO)
|
||||
|
||||
def passwd_change_required(self, username):
|
||||
"""Check whether user need to change password.
|
||||
@@ -259,9 +270,46 @@ class UserOptionsManager(models.Manager):
|
||||
decoded = None
|
||||
return decoded
|
||||
|
||||
def set_file_updates_email_interval(self, username, seconds):
|
||||
return self.set_user_option(username, KEY_FILE_UPDATES_EMAIL_INTERVAL,
|
||||
str(seconds))
|
||||
|
||||
def get_file_updates_email_interval(self, username):
|
||||
val = self.get_user_option(username, KEY_FILE_UPDATES_EMAIL_INTERVAL)
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
logger.error('Failed to convert string %s to int' % val)
|
||||
return None
|
||||
|
||||
def unset_file_updates_email_interval(self, username):
|
||||
return self.unset_user_option(username, KEY_FILE_UPDATES_EMAIL_INTERVAL)
|
||||
|
||||
def set_file_updates_last_emailed_time(self, username, time_dt):
|
||||
return self.set_user_option(
|
||||
username, KEY_FILE_UPDATES_LAST_EMAILED_TIME,
|
||||
time_dt.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
def get_file_updates_last_emailed_time(self, username):
|
||||
val = self.get_user_option(username, KEY_FILE_UPDATES_LAST_EMAILED_TIME)
|
||||
if not val:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.strptime(val, "%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
logger.error('Failed to convert string %s to datetime obj' % val)
|
||||
return None
|
||||
|
||||
def unset_file_updates_last_emailed_time(self, username):
|
||||
return self.unset_user_option(username, KEY_FILE_UPDATES_LAST_EMAILED_TIME)
|
||||
|
||||
|
||||
class UserOptions(models.Model):
|
||||
email = LowerCaseCharField(max_length=255, db_index=True)
|
||||
option_key = models.CharField(max_length=50)
|
||||
option_key = models.CharField(max_length=50, db_index=True)
|
||||
option_val = models.CharField(max_length=50)
|
||||
|
||||
objects = UserOptionsManager()
|
||||
|
@@ -81,6 +81,9 @@ def edit_profile(request):
|
||||
else:
|
||||
webdav_passwd = ''
|
||||
|
||||
email_inverval = UserOptions.objects.get_file_updates_email_interval(username)
|
||||
email_inverval = email_inverval if email_inverval is not None else 0
|
||||
|
||||
resp_dict = {
|
||||
'form': form,
|
||||
'server_crypto': server_crypto,
|
||||
@@ -94,6 +97,7 @@ def edit_profile(request):
|
||||
'ENABLE_CHANGE_PASSWORD': settings.ENABLE_CHANGE_PASSWORD,
|
||||
'ENABLE_WEBDAV_SECRET': settings.ENABLE_WEBDAV_SECRET,
|
||||
'webdav_passwd': webdav_passwd,
|
||||
'email_notification_interval': email_inverval,
|
||||
}
|
||||
|
||||
if has_two_factor_auth():
|
||||
|
@@ -3,7 +3,7 @@ import unittest
|
||||
|
||||
from tests.common.utils import apiurl, urljoin, randstring
|
||||
from tests.api.apitestbase import ApiTestBase
|
||||
from tests.api.urls import ACCOUNTS_URL, ACCOUNT_INFO_URL, PING_URL, \
|
||||
from tests.api.urls import ACCOUNTS_URL, PING_URL, \
|
||||
AUTH_PING_URL
|
||||
|
||||
test_account_username = 'test_%s@test.com' % randstring(10)
|
||||
@@ -12,17 +12,6 @@ test_account_password2 = randstring(20)
|
||||
test_account_url = urljoin(ACCOUNTS_URL, test_account_username)
|
||||
|
||||
class AccountsApiTest(ApiTestBase):
|
||||
def test_check_account_info(self):
|
||||
info = self.get(ACCOUNT_INFO_URL).json()
|
||||
self.assertIsNotNone(info)
|
||||
self.assertEqual(info['email'], self.username)
|
||||
self.assertIsNotNone(info['total'])
|
||||
self.assertIsNotNone(info['usage'])
|
||||
self.assertIsNotNone(info['login_id'])
|
||||
self.assertIsNotNone(info['department'])
|
||||
self.assertIsNotNone(info['contact_email'])
|
||||
self.assertIsNotNone(info['institution'])
|
||||
|
||||
def test_list_accounts(self):
|
||||
# Normal user can not list accounts
|
||||
self.get(ACCOUNTS_URL, expected=403)
|
||||
|
@@ -5,7 +5,6 @@ TOKEN_URL = apiurl('/api2/auth-token/')
|
||||
AUTH_PING_URL = apiurl('/api2/auth/ping/')
|
||||
|
||||
ACCOUNTS_URL = apiurl('/api2/accounts/')
|
||||
ACCOUNT_INFO_URL = apiurl('/api2/account/info/')
|
||||
AVATAR_BASE_URL = apiurl(u'/api2/avatars/')
|
||||
|
||||
REPOS_URL = apiurl('/api2/repos/')
|
||||
|
47
tests/api/views/test_account_info.py
Normal file
47
tests/api/views/test_account_info.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import json
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
import seaserv
|
||||
from seaserv import seafile_api
|
||||
|
||||
from seahub.base.accounts import User
|
||||
from seahub.base.templatetags.seahub_tags import email2nickname
|
||||
from seahub.profile.models import Profile
|
||||
from seahub.test_utils import BaseTestCase
|
||||
from tests.common.utils import randstring
|
||||
|
||||
|
||||
class AccountInfoTest(BaseTestCase):
|
||||
def test_get(self, ):
|
||||
self.login_as(self.user)
|
||||
|
||||
resp = self.client.get('/api2/account/info/')
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
def _do_put(self, val):
|
||||
return self.client.put('/api2/account/info/',
|
||||
val, 'application/x-www-form-urlencoded',
|
||||
)
|
||||
|
||||
def test_update(self, ):
|
||||
self.login_as(self.user)
|
||||
|
||||
resp = self._do_put('name=foo&email_interval=3000')
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
json_resp = json.loads(resp.content)
|
||||
assert json_resp['email_notification_interval'] == 3000
|
||||
assert json_resp['name'] == 'foo'
|
||||
|
||||
def test_update_email_nofification_interval(self, ):
|
||||
self.login_as(self.user)
|
||||
|
||||
resp = self._do_put('email_interval=3000')
|
||||
self.assertEqual(200, resp.status_code)
|
||||
json_resp = json.loads(resp.content)
|
||||
assert json_resp['email_notification_interval'] == 3000
|
||||
|
||||
resp = self._do_put('email_interval=0')
|
||||
self.assertEqual(200, resp.status_code)
|
||||
json_resp = json.loads(resp.content)
|
||||
assert json_resp['email_notification_interval'] is None
|
0
tests/seahub/notifications/management/__init__.py
Normal file
0
tests/seahub/notifications/management/__init__.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# encoding: utf-8
|
||||
import time
|
||||
import datetime
|
||||
from mock import patch
|
||||
|
||||
from django.core import mail
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
from django.test import override_settings
|
||||
|
||||
from seahub.test_utils import BaseTestCase
|
||||
from seahub.options.models import UserOptions
|
||||
|
||||
|
||||
class Record(object):
|
||||
def __init__(self, **entries):
|
||||
self.__dict__.update(entries)
|
||||
|
||||
|
||||
class CommandTest(BaseTestCase):
|
||||
|
||||
def _repo_evs(self, ):
|
||||
l = [
|
||||
{'username': self.user.username, 'commit_id': None, 'obj_type': u'repo', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', 'timestamp': datetime.datetime(2018, 11, 5, 6, 46, 2), 'op_type': u'create', 'path': u'/', 'id': 254L, 'op_user': u'foo@foo.com', u'repo_name': u'tests\\'},
|
||||
{'username': self.user.username, 'commit_id': None, 'obj_type': u'repo', 'repo_id': u'f8dc0bc8-eae0-4063-9beb-790071168794', 'timestamp': datetime.datetime(2018, 11, 6, 9, 52, 6), 'op_type': u'delete', 'path': u'/', 'id': 289L, 'op_user': u'foo@foo.com', u'repo_name': u'123'},
|
||||
{'username': self.user.username, 'commit_id': u'93fb5d8f07e03e5c947599cd7c948965426aafec', 'obj_type': u'repo', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', 'timestamp': datetime.datetime(2018, 11, 7, 2, 35, 34), u'old_repo_name': u'tests\\', 'op_type': u'rename', 'path': u'/', 'id': 306L, 'op_user': u'foo@foo.com', u'repo_name': u'tests\\123'},
|
||||
{'username': self.user.username, 'commit_id': None, 'obj_type': u'repo', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', 'timestamp': datetime.datetime(2018, 11, 7, 3, 13, 2), u'days': 0, 'op_type': u'clean-up-trash', 'path': u'/', 'id': 308L, 'op_user': u'foo@foo.com', u'repo_name': u'tests\\123'},
|
||||
{'username': self.user.username, 'commit_id': None, 'obj_type': u'repo', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', 'timestamp': datetime.datetime(2018, 11, 7, 3, 12, 43), u'days': 3, 'op_type': u'clean-up-trash', 'path': u'/', 'id': 307L, 'op_user': u'foo@foo.com', u'repo_name': u'tests\\123'},
|
||||
]
|
||||
|
||||
return [Record(**x) for x in l]
|
||||
|
||||
def _dir_evs(self, ):
|
||||
l = [
|
||||
{'username': self.user.username, 'commit_id': u'8ff6473e9ef5229a632e1481a1b28d52673220ec', 'obj_type': u'dir', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'0000000000000000000000000000000000000000', 'timestamp': datetime.datetime(2018, 11, 6, 9, 10, 45), 'op_type': u'create', 'path': u'/xx', 'id': 260L, 'op_user': u'foo@foo.com', u'repo_name': u'tests\\'},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'bb3ef321899d2f75ecf56098cb89e6b13c48cff9', 'obj_type': u'dir', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'0000000000000000000000000000000000000000', 'timestamp': datetime.datetime(2018, 11, 6, 9, 27, 3), 'op_type': u'delete', 'path': u'/aa', 'id': 268L, 'op_user': u'foo@foo.com', u'repo_name': u'tests\\'},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'016435e95ace96902ea1bfa1e7688f45804d5aa4', 'obj_type': u'dir', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'95421aa563cf474dce02b7fadc532c17c11cd97a', 'timestamp': datetime.datetime(2018, 11, 6, 9, 38, 32), u'old_path': u'/11', 'op_type': u'move', 'path': u'/new/11', u'repo_name': u'tests\\', 'id': 283L, 'op_user': u'foo@foo.com', u'size': -1},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'712504f1cfd94b0813763a106eb4140a5dba156a', 'obj_type': u'dir', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'4d83a9b62084fef33ec99787425f91df356ae307', 'timestamp': datetime.datetime(2018, 11, 6, 9, 39, 10), u'old_path': u'/new', 'op_type': u'rename', 'path': u'/new2', u'repo_name': u'tests\\', 'id': 284L, 'op_user': u'foo@foo.com', u'size': -1},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'2f7021e0804187b8b09ec82142e0f8b53771cc69', 'obj_type': u'dir', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'0000000000000000000000000000000000000000', 'timestamp': datetime.datetime(2018, 11, 6, 9, 27, 6), 'op_type': u'recover', 'path': u'/aa', 'id': 269L, 'op_user': u'foo@foo.com', u'repo_name': u'tests\\'},
|
||||
]
|
||||
|
||||
return [Record(**x) for x in l]
|
||||
|
||||
def _file_evs(self, ):
|
||||
l = [
|
||||
{'username': self.user.username, 'commit_id': u'658d8487b7e8916ee25703fbdf978b98ab76e3d4', 'obj_type': u'file', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'0000000000000000000000000000000000000000', 'timestamp': datetime.datetime(2018, 11, 6, 9, 38, 23), 'op_type': u'create', 'path': u'/11/new/aa/new/yy/xx/bb/1.txt', u'repo_name': u'tests\\', 'id': 282L, 'op_user': u'foo@foo.com', u'size': 0},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'04df2a831ba485bb6f216f62c1b47883c3e3433c', 'obj_type': u'file', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'd16369af225687671348897a0ad918261866af5d', 'timestamp': datetime.datetime(2018, 11, 6, 9, 0, 14), 'op_type': u'delete', 'path': u'/aa1.txt', u'repo_name': u'tests\\', 'id': 257L, 'op_user': u'foo@foo.com', u'size': 2},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'612f605faa112e4e8928dc08e91c669cea92ef59', 'obj_type': u'file', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'd16369af225687671348897a0ad918261866af5d', 'timestamp': datetime.datetime(2018, 11, 6, 9, 0, 22), 'op_type': u'recover', 'path': u'/aa1.txt', u'repo_name': u'tests\\', 'id': 258L, 'op_user': u'foo@foo.com', u'size': 2},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'106e6e12138bf0e12fbd558da73ff24502807f3e', 'obj_type': u'file', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'28054f8015aada8b5232943d072526541f5227f9', 'timestamp': datetime.datetime(2018, 11, 6, 9, 0, 30), 'op_type': u'edit', 'path': u'/aa1.txt', u'repo_name': u'tests\\', 'id': 259L, 'op_user': u'foo@foo.com', u'size': 4},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'1c9a12a2d8cca79f261eb7c65c118a3ea4f7b850', 'obj_type': u'file', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'28054f8015aada8b5232943d072526541f5227f9', 'timestamp': datetime.datetime(2018, 11, 6, 9, 36, 45), u'old_path': u'/11/new/aa/new/yy/xx/aa4.txt', 'op_type': u'move', 'path': u'/aa4.txt', u'repo_name': u'tests\\', 'id': 279L, 'op_user': u'foo@foo.com', u'size': 4},
|
||||
|
||||
{'username': self.user.username, 'commit_id': u'19cab0f3c53ee00cffe6eaa65f256ccc35a77a72', 'obj_type': u'file', 'repo_id': u'7d6b3f36-3ce1-45f1-8c82-b9532e3162c7', u'obj_id': u'28054f8015aada8b5232943d072526541f5227f9', 'timestamp': datetime.datetime(2018, 11, 6, 9, 36, 59), u'old_path': u'/aa4.txt', 'op_type': u'rename', 'path': u'/aa5.txt', u'repo_name': u'tests\\', 'id': 280L, 'op_user': u'foo@foo.com', u'size': 4},
|
||||
]
|
||||
|
||||
return [Record(**x) for x in l]
|
||||
|
||||
@patch('seahub.notifications.management.commands.send_file_updates.seafevents_api')
|
||||
def test_dir_evs(self, mock_seafevents_api):
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.return_value = self._dir_evs()
|
||||
|
||||
UserOptions.objects.set_file_updates_email_interval(
|
||||
self.user.email, 30)
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
call_command('send_file_updates')
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.assert_called_once()
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
assert mail.outbox[0].to[0] == self.user.username
|
||||
for op in ['Created', 'Deleted', 'Moved', 'Restored', 'Renamed', ]:
|
||||
assert op in mail.outbox[0].body
|
||||
|
||||
@patch('seahub.notifications.management.commands.send_file_updates.seafevents_api')
|
||||
def test_file_evs(self, mock_seafevents_api):
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.return_value = self._file_evs()
|
||||
|
||||
UserOptions.objects.set_file_updates_email_interval(
|
||||
self.user.email, 30)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
call_command('send_file_updates')
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.assert_called_once()
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
assert mail.outbox[0].to[0] == self.user.username
|
||||
|
||||
for op in ['Created', 'Deleted', 'Restored', 'Updated', 'Moved',
|
||||
'Renamed', ]:
|
||||
assert op in mail.outbox[0].body
|
||||
|
||||
@patch('seahub.notifications.management.commands.send_file_updates.seafevents_api')
|
||||
def test_repo_evs(self, mock_seafevents_api):
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.return_value = self._repo_evs()
|
||||
|
||||
UserOptions.objects.set_file_updates_email_interval(
|
||||
self.user.email, 30)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
call_command('send_file_updates')
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.assert_called_once()
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
assert mail.outbox[0].to[0] == self.user.username
|
||||
|
||||
for op in ['Created', 'Deleted', 'Renamed', 'Removed']:
|
||||
assert op in mail.outbox[0].body
|
||||
|
||||
@patch('seahub.notifications.management.commands.send_file_updates.seafevents_api')
|
||||
def test_seafevents_api(self, mock_seafevents_api):
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.return_value = self._repo_evs()
|
||||
|
||||
username = self.user.username
|
||||
UserOptions.objects.set_file_updates_email_interval(username, 30)
|
||||
assert UserOptions.objects.get_file_updates_last_emailed_time(username) is None
|
||||
|
||||
today = datetime.datetime.utcnow().replace(hour=0).replace(
|
||||
minute=0).replace(second=0).replace(microsecond=0)
|
||||
|
||||
before_dt = datetime.datetime.utcnow().replace(microsecond=0)
|
||||
call_command('send_file_updates')
|
||||
after_dt = datetime.datetime.utcnow().replace(microsecond=0)
|
||||
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.assert_called_once()
|
||||
args = mock_seafevents_api.get_user_activities_by_timestamp.call_args[0]
|
||||
assert args[0] == username
|
||||
assert args[1] == today
|
||||
|
||||
last_emailed_dt = UserOptions.objects.get_file_updates_last_emailed_time(username)
|
||||
assert before_dt <= last_emailed_dt
|
||||
assert last_emailed_dt <= after_dt
|
||||
assert last_emailed_dt == args[2]
|
||||
|
||||
@patch('seahub.notifications.management.commands.send_file_updates.seafevents_api')
|
||||
def test_email_interval(self, mock_seafevents_api):
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.return_value = self._repo_evs()
|
||||
|
||||
username = self.user.username
|
||||
assert UserOptions.objects.get_file_updates_last_emailed_time(username) is None
|
||||
|
||||
# assume this command will be finished in 5 seconds
|
||||
UserOptions.objects.set_file_updates_email_interval(username, 5)
|
||||
assert mock_seafevents_api.get_user_activities_by_timestamp.called is False
|
||||
call_command('send_file_updates')
|
||||
assert mock_seafevents_api.get_user_activities_by_timestamp.called is True
|
||||
|
||||
# still within 5 seconds ...
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.reset_mock()
|
||||
assert mock_seafevents_api.get_user_activities_by_timestamp.called is False
|
||||
call_command('send_file_updates')
|
||||
assert mock_seafevents_api.get_user_activities_by_timestamp.called is False
|
||||
|
||||
time.sleep(5) # 5 seconds passed
|
||||
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.reset_mock()
|
||||
assert mock_seafevents_api.get_user_activities_by_timestamp.called is False
|
||||
call_command('send_file_updates')
|
||||
assert mock_seafevents_api.get_user_activities_by_timestamp.called is True
|
||||
|
||||
@override_settings(TIME_ZONE='Asia/Shanghai')
|
||||
@patch('seahub.notifications.management.commands.send_file_updates.seafevents_api')
|
||||
def test_timezone_in_email_body(self, mock_seafevents_api):
|
||||
assert timezone.get_default_timezone_name() == 'Asia/Shanghai'
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.return_value = self._repo_evs()
|
||||
|
||||
UserOptions.objects.set_file_updates_email_interval(
|
||||
self.user.email, 30)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
call_command('send_file_updates')
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
assert '2018-11-05 14:46:02' in mail.outbox[0].body
|
||||
|
||||
@patch('seahub.notifications.management.commands.send_file_updates.seafevents_api')
|
||||
def test_invalid_option_vals(self, mock_seafevents_api):
|
||||
mock_seafevents_api.get_user_activities_by_timestamp.return_value = self._repo_evs()
|
||||
|
||||
UserOptions.objects.set_file_updates_email_interval(
|
||||
self.user.email, 'a')
|
||||
|
||||
try:
|
||||
call_command('send_file_updates')
|
||||
assert True
|
||||
except Exception:
|
||||
assert False
|
@@ -1,9 +1,13 @@
|
||||
from django.utils import timezone
|
||||
|
||||
from seahub.test_utils import BaseTestCase
|
||||
from seahub.options.models import (UserOptions, KEY_USER_GUIDE,
|
||||
VAL_USER_GUIDE_ON, VAL_USER_GUIDE_OFF,
|
||||
KEY_DEFAULT_REPO,
|
||||
KEY_FORCE_2FA, VAL_FORCE_2FA,
|
||||
KEY_WEBDAV_SECRET)
|
||||
from seahub.options.models import (
|
||||
UserOptions, KEY_USER_GUIDE,
|
||||
VAL_USER_GUIDE_ON, VAL_USER_GUIDE_OFF,
|
||||
KEY_DEFAULT_REPO, KEY_FORCE_2FA, VAL_FORCE_2FA,
|
||||
KEY_FILE_UPDATES_EMAIL_INTERVAL, KEY_FILE_UPDATES_LAST_EMAILED_TIME,
|
||||
KEY_WEBDAV_SECRET)
|
||||
|
||||
|
||||
class UserOptionsManagerTest(BaseTestCase):
|
||||
def test_is_user_guide_enabled(self):
|
||||
@@ -85,3 +89,36 @@ class UserOptionsManagerTest(BaseTestCase):
|
||||
|
||||
assert len(UserOptions.objects.filter(email=self.user.email,
|
||||
option_key=KEY_WEBDAV_SECRET)) == 0
|
||||
|
||||
def test_file_udpates_email_interval(self, ):
|
||||
assert len(UserOptions.objects.filter(
|
||||
email=self.user.email, option_key=KEY_FILE_UPDATES_EMAIL_INTERVAL)) == 0
|
||||
|
||||
UserOptions.objects.set_file_updates_email_interval(
|
||||
self.user.email, 300)
|
||||
assert len(UserOptions.objects.filter(
|
||||
email=self.user.email, option_key=KEY_FILE_UPDATES_EMAIL_INTERVAL)) == 1
|
||||
|
||||
interv = UserOptions.objects.get_file_updates_email_interval(self.user.email)
|
||||
assert interv == 300
|
||||
|
||||
UserOptions.objects.unset_file_updates_email_interval(self.user.email)
|
||||
assert len(UserOptions.objects.filter(
|
||||
email=self.user.email, option_key=KEY_FILE_UPDATES_EMAIL_INTERVAL)) == 0
|
||||
|
||||
def test_file_updates_last_emailed_time(self, ):
|
||||
assert len(UserOptions.objects.filter(
|
||||
email=self.user.email, option_key=KEY_FILE_UPDATES_LAST_EMAILED_TIME)) == 0
|
||||
|
||||
t = timezone.now().replace(microsecond=0)
|
||||
|
||||
UserOptions.objects.set_file_updates_last_emailed_time(self.user.email, t)
|
||||
assert len(UserOptions.objects.filter(
|
||||
email=self.user.email, option_key=KEY_FILE_UPDATES_LAST_EMAILED_TIME)) == 1
|
||||
|
||||
val = UserOptions.objects.get_file_updates_last_emailed_time(self.user.email)
|
||||
assert t == val
|
||||
|
||||
UserOptions.objects.unset_file_updates_last_emailed_time(self.user.email)
|
||||
assert len(UserOptions.objects.filter(
|
||||
email=self.user.email, option_key=KEY_FILE_UPDATES_LAST_EMAILED_TIME)) == 0
|
||||
|
Reference in New Issue
Block a user