commit
09577bae6f
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
@ -59,7 +59,7 @@ jobs:
|
||||||
path: geckodriver.log
|
path: geckodriver.log
|
||||||
|
|
||||||
- name: Upload Coverage Results to Codecov
|
- name: Upload Coverage Results to Codecov
|
||||||
uses: codecov/codecov-action@v4.3.1
|
uses: codecov/codecov-action@v4.4.1
|
||||||
with:
|
with:
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,3 @@ $DTDIR/ietf/manage.py populate_yang_model_dirs -v0
|
||||||
|
|
||||||
# Re-run yang checks on active documents
|
# Re-run yang checks on active documents
|
||||||
$DTDIR/ietf/manage.py run_yang_model_checks -v0
|
$DTDIR/ietf/manage.py run_yang_model_checks -v0
|
||||||
|
|
||||||
# Purge older PersonApiKeyEvents
|
|
||||||
$DTDIR/ietf/manage.py purge_old_personal_api_key_events 14
|
|
||||||
|
|
22
bin/weekly
22
bin/weekly
|
@ -1,22 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Weekly datatracker jobs.
|
|
||||||
#
|
|
||||||
# This script is expected to be triggered by cron from
|
|
||||||
# /etc/cron.d/datatracker
|
|
||||||
export LANG=en_US.UTF-8
|
|
||||||
export PYTHONIOENCODING=utf-8
|
|
||||||
|
|
||||||
DTDIR=/a/www/ietf-datatracker/web
|
|
||||||
cd $DTDIR/
|
|
||||||
|
|
||||||
# Set up the virtual environment
|
|
||||||
source $DTDIR/env/bin/activate
|
|
||||||
|
|
||||||
logger -p user.info -t cron "Running $DTDIR/bin/weekly"
|
|
||||||
|
|
||||||
|
|
||||||
# Send out weekly summaries of apikey usage
|
|
||||||
|
|
||||||
$DTDIR/ietf/manage.py send_apikey_usage_emails
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
> See the [IETF Tools Windows Dev guide](https://github.com/ietf-tools/.github/blob/main/docs/windows-dev.md) on how to get started when using Windows.
|
> See the [IETF Tools Windows Dev guide](https://github.com/ietf-tools/.github/blob/main/docs/windows-dev.md) on how to get started when using Windows.
|
||||||
|
|
||||||
2. On Linux, you must also install [Docker Compose](https://docs.docker.com/compose/install/). Docker Desktop for Mac and Windows already include Docker Compose.
|
2. On Linux, you must [install Docker Compose manually](https://docs.docker.com/compose/install/linux/#install-the-plugin-manually) and not install Docker Desktop. On Mac and Windows install Docker Desktop which already includes Docker Compose.
|
||||||
|
|
||||||
2. If you have a copy of the datatracker code checked out already, simply `cd` to the top-level directory.
|
2. If you have a copy of the datatracker code checked out already, simply `cd` to the top-level directory.
|
||||||
|
|
||||||
|
@ -183,3 +183,18 @@ The content of the source files will be copied into the target `.ics` files. Mak
|
||||||
### Missing assets in the data folder
|
### Missing assets in the data folder
|
||||||
|
|
||||||
Because including all assets in the image would significantly increase the file size, they are not included by default. You can however fetch them by running the **Fetch assets via rsync** task in VS Code or run manually the script `docker/scripts/app-rsync-extras.sh`
|
Because including all assets in the image would significantly increase the file size, they are not included by default. You can however fetch them by running the **Fetch assets via rsync** task in VS Code or run manually the script `docker/scripts/app-rsync-extras.sh`
|
||||||
|
|
||||||
|
|
||||||
|
### Linux file permissions leaking to the host system
|
||||||
|
|
||||||
|
If on the host filesystem you have permissions that look like this,
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ls -la
|
||||||
|
total 4624
|
||||||
|
drwxrwxr-x 2 100999 100999 4096 May 25 07:56 bin
|
||||||
|
drwxrwxr-x 5 100999 100999 4096 May 25 07:56 client
|
||||||
|
(etc...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Try uninstalling Docker Desktop and installing Docker Compose manually. The Docker Compose bundled with Docker Desktop is incompatible with our software. See also [Rootless Docker: file ownership changes #3343](https://github.com/lando/lando/issues/3343), [Docker context desktop-linux has container permission issues #75](https://github.com/docker/desktop-linux/issues/75).
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
WORKSPACEDIR="/workspace"
|
WORKSPACEDIR="/workspace"
|
||||||
|
|
||||||
|
# Handle Linux host mounting the workspace dir as root
|
||||||
|
if [ ! -O "${WORKSPACEDIR}/ietf" ]; then
|
||||||
|
sudo chown -R dev:dev $WORKSPACEDIR
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start rsyslog service
|
||||||
sudo service rsyslog start &>/dev/null
|
sudo service rsyslog start &>/dev/null
|
||||||
|
|
||||||
# Add /workspace as a safe git directory
|
# Add /workspace as a safe git directory
|
||||||
|
|
|
@ -30,7 +30,7 @@ from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFact
|
||||||
from ietf.group.factories import RoleFactory
|
from ietf.group.factories import RoleFactory
|
||||||
from ietf.meeting.factories import MeetingFactory, SessionFactory
|
from ietf.meeting.factories import MeetingFactory, SessionFactory
|
||||||
from ietf.meeting.models import Session
|
from ietf.meeting.models import Session
|
||||||
from ietf.nomcom.models import Volunteer, NomCom
|
from ietf.nomcom.models import Volunteer
|
||||||
from ietf.nomcom.factories import NomComFactory, nomcom_kwargs_for_year
|
from ietf.nomcom.factories import NomComFactory, nomcom_kwargs_for_year
|
||||||
from ietf.person.factories import PersonFactory, random_faker, EmailFactory
|
from ietf.person.factories import PersonFactory, random_faker, EmailFactory
|
||||||
from ietf.person.models import Email, User
|
from ietf.person.models import Email, User
|
||||||
|
@ -828,7 +828,7 @@ class CustomApiTests(TestCase):
|
||||||
'reg_type': 'onsite',
|
'reg_type': 'onsite',
|
||||||
'ticket_type': '',
|
'ticket_type': '',
|
||||||
'checkedin': 'False',
|
'checkedin': 'False',
|
||||||
'is_nomcom_volunteer': 'True',
|
'is_nomcom_volunteer': 'False',
|
||||||
}
|
}
|
||||||
person = PersonFactory()
|
person = PersonFactory()
|
||||||
reg['email'] = person.email().address
|
reg['email'] = person.email().address
|
||||||
|
@ -842,16 +842,22 @@ class CustomApiTests(TestCase):
|
||||||
# create appropriate group and nomcom objects
|
# create appropriate group and nomcom objects
|
||||||
nomcom = NomComFactory.create(is_accepting_volunteers=True, **nomcom_kwargs_for_year(year))
|
nomcom = NomComFactory.create(is_accepting_volunteers=True, **nomcom_kwargs_for_year(year))
|
||||||
url = urlreverse('ietf.api.views.api_new_meeting_registration')
|
url = urlreverse('ietf.api.views.api_new_meeting_registration')
|
||||||
r = self.client.post(url, reg)
|
|
||||||
self.assertContains(r, 'Invalid apikey', status_code=403)
|
|
||||||
oidcp = PersonFactory(user__is_staff=True)
|
oidcp = PersonFactory(user__is_staff=True)
|
||||||
# Make sure 'oidcp' has an acceptable role
|
# Make sure 'oidcp' has an acceptable role
|
||||||
RoleFactory(name_id='robot', person=oidcp, email=oidcp.email(), group__acronym='secretariat')
|
RoleFactory(name_id='robot', person=oidcp, email=oidcp.email(), group__acronym='secretariat')
|
||||||
key = PersonalApiKey.objects.create(person=oidcp, endpoint=url)
|
key = PersonalApiKey.objects.create(person=oidcp, endpoint=url)
|
||||||
reg['apikey'] = key.hash()
|
reg['apikey'] = key.hash()
|
||||||
|
|
||||||
|
# first test is_nomcom_volunteer False
|
||||||
r = self.client.post(url, reg)
|
r = self.client.post(url, reg)
|
||||||
nomcom = NomCom.objects.last()
|
|
||||||
self.assertContains(r, "Accepted, New registration", status_code=202)
|
self.assertContains(r, "Accepted, New registration", status_code=202)
|
||||||
|
# assert no Volunteers exists
|
||||||
|
self.assertEqual(Volunteer.objects.count(), 0)
|
||||||
|
|
||||||
|
# test is_nomcom_volunteer True
|
||||||
|
reg['is_nomcom_volunteer'] = 'True'
|
||||||
|
r = self.client.post(url, reg)
|
||||||
|
self.assertContains(r, "Accepted, Updated registration", status_code=202)
|
||||||
# assert Volunteer exists
|
# assert Volunteer exists
|
||||||
self.assertEqual(Volunteer.objects.count(), 1)
|
self.assertEqual(Volunteer.objects.count(), 1)
|
||||||
volunteer = Volunteer.objects.last()
|
volunteer = Volunteer.objects.last()
|
||||||
|
|
|
@ -212,7 +212,7 @@ def api_new_meeting_registration(request):
|
||||||
response += ", Email sent"
|
response += ", Email sent"
|
||||||
|
|
||||||
# handle nomcom volunteer
|
# handle nomcom volunteer
|
||||||
if data['is_nomcom_volunteer'] and object.person:
|
if request.POST.get('is_nomcom_volunteer', 'false').lower() == 'true' and object.person:
|
||||||
try:
|
try:
|
||||||
nomcom = NomCom.objects.get(is_accepting_volunteers=True)
|
nomcom = NomCom.objects.get(is_accepting_volunteers=True)
|
||||||
except (NomCom.DoesNotExist, NomCom.MultipleObjectsReturned):
|
except (NomCom.DoesNotExist, NomCom.MultipleObjectsReturned):
|
||||||
|
|
|
@ -61,6 +61,13 @@ def generate_files(records, adest, vdest, postconfirm, vdomain):
|
||||||
shutil.move(vpath, vdest)
|
shutil.move(vpath, vdest)
|
||||||
|
|
||||||
|
|
||||||
|
def directory_path(val):
|
||||||
|
p = Path(val)
|
||||||
|
if p.is_dir():
|
||||||
|
return p
|
||||||
|
else:
|
||||||
|
raise argparse.ArgumentTypeError(f"{p} is not a directory")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Convert a JSON stream of draft alias definitions into alias / virtual alias files."
|
description="Convert a JSON stream of draft alias definitions into alias / virtual alias files."
|
||||||
|
@ -73,7 +80,7 @@ if __name__ == "__main__":
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output-dir",
|
"--output-dir",
|
||||||
default="./",
|
default="./",
|
||||||
type=Path,
|
type=directory_path,
|
||||||
help="Destination for output files.",
|
help="Destination for output files.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
@ -87,8 +94,6 @@ if __name__ == "__main__":
|
||||||
help=f"Virtual domain (defaults to {VDOMAIN}_",
|
help=f"Virtual domain (defaults to {VDOMAIN}_",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if not args.output_dir.is_dir():
|
|
||||||
sys.stderr.write("Error: output-dir must be a directory")
|
|
||||||
data = json.load(sys.stdin)
|
data = json.load(sys.stdin)
|
||||||
generate_files(
|
generate_files(
|
||||||
data["aliases"],
|
data["aliases"],
|
||||||
|
|
|
@ -32,7 +32,7 @@ from ietf.person.utils import get_active_ads
|
||||||
from ietf.utils.test_utils import TestCase, login_testing_unauthorized
|
from ietf.utils.test_utils import TestCase, login_testing_unauthorized
|
||||||
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
|
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
|
||||||
from ietf.utils.text import unwrap
|
from ietf.utils.text import unwrap
|
||||||
from ietf.utils.timezone import date_today
|
from ietf.utils.timezone import date_today, datetime_today
|
||||||
|
|
||||||
|
|
||||||
class EditPositionTests(TestCase):
|
class EditPositionTests(TestCase):
|
||||||
|
@ -529,6 +529,7 @@ class BallotWriteupsTests(TestCase):
|
||||||
login_testing_unauthorized(self, "secretary", url)
|
login_testing_unauthorized(self, "secretary", url)
|
||||||
|
|
||||||
# expect warning about issuing a ballot before IETF Last Call is done
|
# expect warning about issuing a ballot before IETF Last Call is done
|
||||||
|
# No last call has yet been issued
|
||||||
r = self.client.get(url)
|
r = self.client.get(url)
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
q = PyQuery(r.content)
|
q = PyQuery(r.content)
|
||||||
|
@ -536,6 +537,38 @@ class BallotWriteupsTests(TestCase):
|
||||||
self.assertTrue(q('[class=text-danger]:contains("not completed IETF Last Call")'))
|
self.assertTrue(q('[class=text-danger]:contains("not completed IETF Last Call")'))
|
||||||
self.assertTrue(q('[type=submit]:contains("Save")'))
|
self.assertTrue(q('[type=submit]:contains("Save")'))
|
||||||
|
|
||||||
|
# Last call exists but hasn't expired
|
||||||
|
LastCallDocEvent.objects.create(
|
||||||
|
doc=draft,
|
||||||
|
expires=datetime_today()+datetime.timedelta(days=14),
|
||||||
|
by=Person.objects.get(name="(System)")
|
||||||
|
)
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
q = PyQuery(r.content)
|
||||||
|
self.assertTrue(q('[class=text-danger]:contains("not completed IETF Last Call")'))
|
||||||
|
|
||||||
|
# Last call exists and has expired
|
||||||
|
LastCallDocEvent.objects.filter(doc=draft).update(expires=datetime_today()-datetime.timedelta(days=2))
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
q = PyQuery(r.content)
|
||||||
|
self.assertFalse(q('[class=text-danger]:contains("not completed IETF Last Call")'))
|
||||||
|
|
||||||
|
for state_slug in ["lc", "watching", "ad-eval"]:
|
||||||
|
draft.set_state(State.objects.get(type="draft-iesg",slug=state_slug))
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
q = PyQuery(r.content)
|
||||||
|
self.assertTrue(q('[class=text-danger]:contains("It would be unexpected to issue a ballot while in this state.")'))
|
||||||
|
|
||||||
|
draft.set_state(State.objects.get(type="draft-iesg",slug="writeupw"))
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
q = PyQuery(r.content)
|
||||||
|
self.assertFalse(q('[class=text-danger]:contains("It would be unexpected to issue a ballot while in this state.")'))
|
||||||
|
|
||||||
|
|
||||||
def test_edit_approval_text(self):
|
def test_edit_approval_text(self):
|
||||||
ad = Person.objects.get(user__username="ad")
|
ad = Person.objects.get(user__username="ad")
|
||||||
draft = WgDraftFactory(ad=ad,states=[('draft','active'),('draft-iesg','iesg-eva')],intended_std_level_id='ps',group__parent=Group.objects.get(acronym='farfut'))
|
draft = WgDraftFactory(ad=ad,states=[('draft','active'),('draft-iesg','iesg-eva')],intended_std_level_id='ps',group__parent=Group.objects.get(acronym='farfut'))
|
||||||
|
|
|
@ -38,6 +38,7 @@ from ietf.mailtrigger.forms import CcSelectForm
|
||||||
from ietf.message.utils import infer_message
|
from ietf.message.utils import infer_message
|
||||||
from ietf.name.models import BallotPositionName, DocTypeName
|
from ietf.name.models import BallotPositionName, DocTypeName
|
||||||
from ietf.person.models import Person
|
from ietf.person.models import Person
|
||||||
|
from ietf.utils.fields import ModelMultipleChoiceField
|
||||||
from ietf.utils.mail import send_mail_text, send_mail_preformatted
|
from ietf.utils.mail import send_mail_text, send_mail_preformatted
|
||||||
from ietf.utils.decorators import require_api_key
|
from ietf.utils.decorators import require_api_key
|
||||||
from ietf.utils.response import permission_denied
|
from ietf.utils.response import permission_denied
|
||||||
|
@ -686,7 +687,8 @@ def ballot_writeupnotes(request, name):
|
||||||
dict(doc=doc,
|
dict(doc=doc,
|
||||||
back_url=doc.get_absolute_url(),
|
back_url=doc.get_absolute_url(),
|
||||||
ballot_issued=bool(doc.latest_event(type="sent_ballot_announcement")),
|
ballot_issued=bool(doc.latest_event(type="sent_ballot_announcement")),
|
||||||
ballot_issue_danger=bool(prev_state.slug in ['ad-eval', 'lc']),
|
warn_lc = not doc.docevent_set.filter(lastcalldocevent__expires__date__lt=date_today(DEADLINE_TZINFO)).exists(),
|
||||||
|
warn_unexpected_state= prev_state if bool(prev_state.slug in ['watching', 'ad-eval', 'lc']) else None,
|
||||||
ballot_writeup_form=form,
|
ballot_writeup_form=form,
|
||||||
need_intended_status=need_intended_status,
|
need_intended_status=need_intended_status,
|
||||||
))
|
))
|
||||||
|
@ -931,7 +933,7 @@ def approve_ballot(request, name):
|
||||||
|
|
||||||
|
|
||||||
class ApproveDownrefsForm(forms.Form):
|
class ApproveDownrefsForm(forms.Form):
|
||||||
checkboxes = forms.ModelMultipleChoiceField(
|
checkboxes = ModelMultipleChoiceField(
|
||||||
widget = forms.CheckboxSelectMultiple,
|
widget = forms.CheckboxSelectMultiple,
|
||||||
queryset = RelatedDocument.objects.none(), )
|
queryset = RelatedDocument.objects.none(), )
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,7 @@ from ietf.person.models import Person, Email
|
||||||
from ietf.utils.mail import send_mail, send_mail_message, on_behalf_of
|
from ietf.utils.mail import send_mail, send_mail_message, on_behalf_of
|
||||||
from ietf.utils.textupload import get_cleaned_text_file_content
|
from ietf.utils.textupload import get_cleaned_text_file_content
|
||||||
from ietf.utils import log
|
from ietf.utils import log
|
||||||
|
from ietf.utils.fields import ModelMultipleChoiceField
|
||||||
from ietf.utils.response import permission_denied
|
from ietf.utils.response import permission_denied
|
||||||
from ietf.utils.timezone import datetime_today, DEADLINE_TZINFO
|
from ietf.utils.timezone import datetime_today, DEADLINE_TZINFO
|
||||||
|
|
||||||
|
@ -390,9 +391,9 @@ def replaces(request, name):
|
||||||
))
|
))
|
||||||
|
|
||||||
class SuggestedReplacesForm(forms.Form):
|
class SuggestedReplacesForm(forms.Form):
|
||||||
replaces = forms.ModelMultipleChoiceField(queryset=Document.objects.all(),
|
replaces = ModelMultipleChoiceField(queryset=Document.objects.all(),
|
||||||
label="Suggestions", required=False, widget=forms.CheckboxSelectMultiple,
|
label="Suggestions", required=False, widget=forms.CheckboxSelectMultiple,
|
||||||
help_text="Select only the documents that are replaced by this document")
|
help_text="Select only the documents that are replaced by this document")
|
||||||
comment = forms.CharField(label="Optional comment", widget=forms.Textarea, required=False, strip=False)
|
comment = forms.CharField(label="Optional comment", widget=forms.Textarea, required=False, strip=False)
|
||||||
|
|
||||||
def __init__(self, suggested, *args, **kwargs):
|
def __init__(self, suggested, *args, **kwargs):
|
||||||
|
@ -1601,7 +1602,7 @@ class ChangeStreamStateForm(forms.Form):
|
||||||
new_state = forms.ModelChoiceField(queryset=State.objects.filter(used=True), label='State' )
|
new_state = forms.ModelChoiceField(queryset=State.objects.filter(used=True), label='State' )
|
||||||
weeks = forms.IntegerField(label='Expected weeks in state',required=False)
|
weeks = forms.IntegerField(label='Expected weeks in state',required=False)
|
||||||
comment = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional comment for the document history.", strip=False)
|
comment = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional comment for the document history.", strip=False)
|
||||||
tags = forms.ModelMultipleChoiceField(queryset=DocTagName.objects.filter(used=True), widget=forms.CheckboxSelectMultiple, required=False)
|
tags = ModelMultipleChoiceField(queryset=DocTagName.objects.filter(used=True), widget=forms.CheckboxSelectMultiple, required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
doc = kwargs.pop("doc")
|
doc = kwargs.pop("doc")
|
||||||
|
|
|
@ -52,7 +52,7 @@ from ietf.utils.text import strip_prefix, xslugify
|
||||||
from ietf.utils.textupload import get_cleaned_text_file_content
|
from ietf.utils.textupload import get_cleaned_text_file_content
|
||||||
from ietf.utils.mail import send_mail_message
|
from ietf.utils.mail import send_mail_message
|
||||||
from ietf.mailtrigger.utils import gather_address_lists
|
from ietf.mailtrigger.utils import gather_address_lists
|
||||||
from ietf.utils.fields import MultiEmailField
|
from ietf.utils.fields import ModelMultipleChoiceField, MultiEmailField
|
||||||
from ietf.utils.http import is_ajax
|
from ietf.utils.http import is_ajax
|
||||||
from ietf.utils.response import permission_denied
|
from ietf.utils.response import permission_denied
|
||||||
from ietf.utils.timezone import date_today, DEADLINE_TZINFO
|
from ietf.utils.timezone import date_today, DEADLINE_TZINFO
|
||||||
|
@ -68,7 +68,7 @@ def clean_doc_revision(doc, rev):
|
||||||
return rev
|
return rev
|
||||||
|
|
||||||
class RequestReviewForm(forms.ModelForm):
|
class RequestReviewForm(forms.ModelForm):
|
||||||
team = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), widget=forms.CheckboxSelectMultiple)
|
team = ModelMultipleChoiceField(queryset=Group.objects.all(), widget=forms.CheckboxSelectMultiple)
|
||||||
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" })
|
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" })
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -69,6 +69,7 @@ from ietf.name.models import DocTagName, DocTypeName, StreamName
|
||||||
from ietf.person.models import Person
|
from ietf.person.models import Person
|
||||||
from ietf.person.utils import get_active_ads
|
from ietf.person.utils import get_active_ads
|
||||||
from ietf.utils.draft_search import normalize_draftname
|
from ietf.utils.draft_search import normalize_draftname
|
||||||
|
from ietf.utils.fields import ModelMultipleChoiceField
|
||||||
from ietf.utils.log import log
|
from ietf.utils.log import log
|
||||||
from ietf.doc.utils_search import prepare_document_table, doc_type, doc_state, doc_type_name, AD_WORKLOAD
|
from ietf.doc.utils_search import prepare_document_table, doc_type, doc_state, doc_type_name, AD_WORKLOAD
|
||||||
from ietf.ietfauth.utils import has_role
|
from ietf.ietfauth.utils import has_role
|
||||||
|
@ -100,7 +101,7 @@ class SearchForm(forms.Form):
|
||||||
("ad", "AD"), ("-ad", "AD (desc)"), ),
|
("ad", "AD"), ("-ad", "AD (desc)"), ),
|
||||||
required=False, widget=forms.HiddenInput)
|
required=False, widget=forms.HiddenInput)
|
||||||
|
|
||||||
doctypes = forms.ModelMultipleChoiceField(queryset=DocTypeName.objects.filter(used=True).exclude(slug__in=('draft', 'rfc', 'bcp', 'std', 'fyi', 'liai-att')).order_by('name'), required=False)
|
doctypes = ModelMultipleChoiceField(queryset=DocTypeName.objects.filter(used=True).exclude(slug__in=('draft', 'rfc', 'bcp', 'std', 'fyi', 'liai-att')).order_by('name'), required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(SearchForm, self).__init__(*args, **kwargs)
|
super(SearchForm, self).__init__(*args, **kwargs)
|
||||||
|
|
|
@ -1,52 +0,0 @@
|
||||||
# Copyright The IETF Trust 2017-2020, All Rights Reserved
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from textwrap import dedent
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
import debug # pyflakes:ignore
|
|
||||||
|
|
||||||
from ietf.person.models import PersonalApiKey, PersonApiKeyEvent
|
|
||||||
from ietf.utils.mail import send_mail
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
"""
|
|
||||||
Send out emails to all persons who have personal API keys about usage.
|
|
||||||
|
|
||||||
Usage is show over the given period, where the default period is 7 days.
|
|
||||||
"""
|
|
||||||
|
|
||||||
help = dedent(__doc__).strip()
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument('-d', '--days', dest='days', type=int, default=7,
|
|
||||||
help='The period over which to show usage.')
|
|
||||||
|
|
||||||
def handle(self, *filenames, **options):
|
|
||||||
"""
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.verbosity = int(options.get('verbosity'))
|
|
||||||
days = options.get('days')
|
|
||||||
|
|
||||||
keys = PersonalApiKey.objects.filter(valid=True)
|
|
||||||
for key in keys:
|
|
||||||
earliest = timezone.now() - datetime.timedelta(days=days)
|
|
||||||
events = PersonApiKeyEvent.objects.filter(key=key, time__gt=earliest)
|
|
||||||
count = events.count()
|
|
||||||
events = events[:32]
|
|
||||||
if count:
|
|
||||||
key_name = key.hash()[:8]
|
|
||||||
subject = "API key usage for key '%s' for the last %s days" %(key_name, days)
|
|
||||||
to = key.person.email_address()
|
|
||||||
frm = settings.DEFAULT_FROM_EMAIL
|
|
||||||
send_mail(None, to, frm, subject, 'utils/apikey_usage_report.txt', {'person':key.person,
|
|
||||||
'days':days, 'key':key, 'key_name':key_name, 'count':count, 'events':events, } )
|
|
||||||
|
|
|
@ -41,6 +41,7 @@ from ietf.meeting.factories import MeetingFactory
|
||||||
from ietf.nomcom.factories import NomComFactory
|
from ietf.nomcom.factories import NomComFactory
|
||||||
from ietf.person.factories import PersonFactory, EmailFactory, UserFactory, PersonalApiKeyFactory
|
from ietf.person.factories import PersonFactory, EmailFactory, UserFactory, PersonalApiKeyFactory
|
||||||
from ietf.person.models import Person, Email, PersonalApiKey
|
from ietf.person.models import Person, Email, PersonalApiKey
|
||||||
|
from ietf.person.tasks import send_apikey_usage_emails_task
|
||||||
from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory
|
from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory
|
||||||
from ietf.review.models import ReviewWish, UnavailablePeriod
|
from ietf.review.models import ReviewWish, UnavailablePeriod
|
||||||
from ietf.stats.models import MeetingRegistration
|
from ietf.stats.models import MeetingRegistration
|
||||||
|
@ -50,8 +51,6 @@ from ietf.utils.test_utils import TestCase, login_testing_unauthorized
|
||||||
from ietf.utils.timezone import date_today
|
from ietf.utils.timezone import date_today
|
||||||
|
|
||||||
|
|
||||||
import ietf.ietfauth.views
|
|
||||||
|
|
||||||
if os.path.exists(settings.HTPASSWD_COMMAND):
|
if os.path.exists(settings.HTPASSWD_COMMAND):
|
||||||
skip_htpasswd_command = False
|
skip_htpasswd_command = False
|
||||||
skip_message = ""
|
skip_message = ""
|
||||||
|
@ -83,30 +82,30 @@ class IetfAuthTests(TestCase):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
def test_index(self):
|
def test_index(self):
|
||||||
self.assertEqual(self.client.get(urlreverse(ietf.ietfauth.views.index)).status_code, 200)
|
self.assertEqual(self.client.get(urlreverse("ietf.ietfauth.views.index")).status_code, 200)
|
||||||
|
|
||||||
def test_login_and_logout(self):
|
def test_login_and_logout(self):
|
||||||
PersonFactory(user__username='plain')
|
PersonFactory(user__username='plain')
|
||||||
|
|
||||||
# try logging in without a next
|
# try logging in without a next
|
||||||
r = self.client.get(urlreverse(ietf.ietfauth.views.login))
|
r = self.client.get(urlreverse("ietf.ietfauth.views.login"))
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
r = self.client.post(urlreverse(ietf.ietfauth.views.login), {"username":"plain", "password":"plain+password"})
|
r = self.client.post(urlreverse("ietf.ietfauth.views.login"), {"username":"plain", "password":"plain+password"})
|
||||||
self.assertEqual(r.status_code, 302)
|
self.assertEqual(r.status_code, 302)
|
||||||
self.assertEqual(urlsplit(r["Location"])[2], urlreverse(ietf.ietfauth.views.profile))
|
self.assertEqual(urlsplit(r["Location"])[2], urlreverse("ietf.ietfauth.views.profile"))
|
||||||
|
|
||||||
# try logging out
|
# try logging out
|
||||||
r = self.client.post(urlreverse('django.contrib.auth.views.logout'), {})
|
r = self.client.post(urlreverse('django.contrib.auth.views.logout'), {})
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
self.assertNotContains(r, "accounts/logout")
|
self.assertNotContains(r, "accounts/logout")
|
||||||
|
|
||||||
r = self.client.get(urlreverse(ietf.ietfauth.views.profile))
|
r = self.client.get(urlreverse("ietf.ietfauth.views.profile"))
|
||||||
self.assertEqual(r.status_code, 302)
|
self.assertEqual(r.status_code, 302)
|
||||||
self.assertEqual(urlsplit(r["Location"])[2], urlreverse(ietf.ietfauth.views.login))
|
self.assertEqual(urlsplit(r["Location"])[2], urlreverse("ietf.ietfauth.views.login"))
|
||||||
|
|
||||||
# try logging in with a next
|
# try logging in with a next
|
||||||
r = self.client.post(urlreverse(ietf.ietfauth.views.login) + "?next=/foobar", {"username":"plain", "password":"plain+password"})
|
r = self.client.post(urlreverse("ietf.ietfauth.views.login") + "?next=/foobar", {"username":"plain", "password":"plain+password"})
|
||||||
self.assertEqual(r.status_code, 302)
|
self.assertEqual(r.status_code, 302)
|
||||||
self.assertEqual(urlsplit(r["Location"])[2], "/foobar")
|
self.assertEqual(urlsplit(r["Location"])[2], "/foobar")
|
||||||
|
|
||||||
|
@ -137,19 +136,19 @@ class IetfAuthTests(TestCase):
|
||||||
# try with a trivial next
|
# try with a trivial next
|
||||||
_test_login("/")
|
_test_login("/")
|
||||||
# try with a next that requires login
|
# try with a next that requires login
|
||||||
_test_login(urlreverse(ietf.ietfauth.views.profile))
|
_test_login(urlreverse("ietf.ietfauth.views.profile"))
|
||||||
|
|
||||||
def test_login_with_different_email(self):
|
def test_login_with_different_email(self):
|
||||||
person = PersonFactory(user__username='plain')
|
person = PersonFactory(user__username='plain')
|
||||||
email = EmailFactory(person=person)
|
email = EmailFactory(person=person)
|
||||||
|
|
||||||
# try logging in without a next
|
# try logging in without a next
|
||||||
r = self.client.get(urlreverse(ietf.ietfauth.views.login))
|
r = self.client.get(urlreverse("ietf.ietfauth.views.login"))
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
r = self.client.post(urlreverse(ietf.ietfauth.views.login), {"username":email, "password":"plain+password"})
|
r = self.client.post(urlreverse("ietf.ietfauth.views.login"), {"username":email, "password":"plain+password"})
|
||||||
self.assertEqual(r.status_code, 302)
|
self.assertEqual(r.status_code, 302)
|
||||||
self.assertEqual(urlsplit(r["Location"])[2], urlreverse(ietf.ietfauth.views.profile))
|
self.assertEqual(urlsplit(r["Location"])[2], urlreverse("ietf.ietfauth.views.profile"))
|
||||||
|
|
||||||
def extract_confirm_url(self, confirm_email):
|
def extract_confirm_url(self, confirm_email):
|
||||||
# dig out confirm_email link
|
# dig out confirm_email link
|
||||||
|
@ -176,7 +175,7 @@ class IetfAuthTests(TestCase):
|
||||||
# For the lowered barrier to account creation period, we are disabling this kind of failure
|
# For the lowered barrier to account creation period, we are disabling this kind of failure
|
||||||
# def test_create_account_failure(self):
|
# def test_create_account_failure(self):
|
||||||
|
|
||||||
# url = urlreverse(ietf.ietfauth.views.create_account)
|
# url = urlreverse("ietf.ietfauth.views.create_account")
|
||||||
|
|
||||||
# # get
|
# # get
|
||||||
# r = self.client.get(url)
|
# r = self.client.get(url)
|
||||||
|
@ -195,7 +194,7 @@ class IetfAuthTests(TestCase):
|
||||||
self.assertTrue("Additional Assistance Required" in r)
|
self.assertTrue("Additional Assistance Required" in r)
|
||||||
|
|
||||||
def register(self, email):
|
def register(self, email):
|
||||||
url = urlreverse(ietf.ietfauth.views.create_account)
|
url = urlreverse("ietf.ietfauth.views.create_account")
|
||||||
|
|
||||||
# register email
|
# register email
|
||||||
empty_outbox()
|
empty_outbox()
|
||||||
|
@ -240,7 +239,7 @@ class IetfAuthTests(TestCase):
|
||||||
note = get_payload_text(outbox[-1])
|
note = get_payload_text(outbox[-1])
|
||||||
self.assertIn(email, note)
|
self.assertIn(email, note)
|
||||||
self.assertIn("A datatracker account for that email already exists", note)
|
self.assertIn("A datatracker account for that email already exists", note)
|
||||||
self.assertIn(urlreverse(ietf.ietfauth.views.password_reset), note)
|
self.assertIn(urlreverse("ietf.ietfauth.views.password_reset"), note)
|
||||||
|
|
||||||
def test_ietfauth_profile(self):
|
def test_ietfauth_profile(self):
|
||||||
EmailFactory(person__user__username='plain')
|
EmailFactory(person__user__username='plain')
|
||||||
|
@ -249,7 +248,7 @@ class IetfAuthTests(TestCase):
|
||||||
username = "plain"
|
username = "plain"
|
||||||
email_address = Email.objects.filter(person__user__username=username).first().address
|
email_address = Email.objects.filter(person__user__username=username).first().address
|
||||||
|
|
||||||
url = urlreverse(ietf.ietfauth.views.profile)
|
url = urlreverse("ietf.ietfauth.views.profile")
|
||||||
login_testing_unauthorized(self, username, url)
|
login_testing_unauthorized(self, username, url)
|
||||||
|
|
||||||
|
|
||||||
|
@ -400,7 +399,7 @@ class IetfAuthTests(TestCase):
|
||||||
def test_email_case_insensitive_protection(self):
|
def test_email_case_insensitive_protection(self):
|
||||||
EmailFactory(address="TestAddress@example.net")
|
EmailFactory(address="TestAddress@example.net")
|
||||||
person = PersonFactory()
|
person = PersonFactory()
|
||||||
url = urlreverse(ietf.ietfauth.views.profile)
|
url = urlreverse("ietf.ietfauth.views.profile")
|
||||||
login_testing_unauthorized(self, person.user.username, url)
|
login_testing_unauthorized(self, person.user.username, url)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
|
@ -441,7 +440,7 @@ class IetfAuthTests(TestCase):
|
||||||
|
|
||||||
|
|
||||||
def test_reset_password(self):
|
def test_reset_password(self):
|
||||||
url = urlreverse(ietf.ietfauth.views.password_reset)
|
url = urlreverse("ietf.ietfauth.views.password_reset")
|
||||||
email = 'someone@example.com'
|
email = 'someone@example.com'
|
||||||
password = 'foobar'
|
password = 'foobar'
|
||||||
|
|
||||||
|
@ -507,7 +506,7 @@ class IetfAuthTests(TestCase):
|
||||||
self.assertEqual(len(outbox), 1)
|
self.assertEqual(len(outbox), 1)
|
||||||
confirm_url = self.extract_confirm_url(outbox[-1])
|
confirm_url = self.extract_confirm_url(outbox[-1])
|
||||||
|
|
||||||
r = self.client.post(urlreverse(ietf.ietfauth.views.login), {'username': email, 'password': password})
|
r = self.client.post(urlreverse("ietf.ietfauth.views.login"), {'username': email, 'password': password})
|
||||||
|
|
||||||
r = self.client.get(confirm_url)
|
r = self.client.get(confirm_url)
|
||||||
self.assertEqual(r.status_code, 404)
|
self.assertEqual(r.status_code, 404)
|
||||||
|
@ -589,7 +588,7 @@ class IetfAuthTests(TestCase):
|
||||||
availability="unavailable",
|
availability="unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = urlreverse(ietf.ietfauth.views.review_overview)
|
url = urlreverse("ietf.ietfauth.views.review_overview")
|
||||||
|
|
||||||
login_testing_unauthorized(self, reviewer.user.username, url)
|
login_testing_unauthorized(self, reviewer.user.username, url)
|
||||||
|
|
||||||
|
@ -633,10 +632,9 @@ class IetfAuthTests(TestCase):
|
||||||
|
|
||||||
|
|
||||||
def test_change_password(self):
|
def test_change_password(self):
|
||||||
|
chpw_url = urlreverse("ietf.ietfauth.views.change_password")
|
||||||
chpw_url = urlreverse(ietf.ietfauth.views.change_password)
|
prof_url = urlreverse("ietf.ietfauth.views.profile")
|
||||||
prof_url = urlreverse(ietf.ietfauth.views.profile)
|
login_url = urlreverse("ietf.ietfauth.views.login")
|
||||||
login_url = urlreverse(ietf.ietfauth.views.login)
|
|
||||||
redir_url = '%s?next=%s' % (login_url, chpw_url)
|
redir_url = '%s?next=%s' % (login_url, chpw_url)
|
||||||
|
|
||||||
# get without logging in
|
# get without logging in
|
||||||
|
@ -681,9 +679,9 @@ class IetfAuthTests(TestCase):
|
||||||
|
|
||||||
def test_change_username(self):
|
def test_change_username(self):
|
||||||
|
|
||||||
chun_url = urlreverse(ietf.ietfauth.views.change_username)
|
chun_url = urlreverse("ietf.ietfauth.views.change_username")
|
||||||
prof_url = urlreverse(ietf.ietfauth.views.profile)
|
prof_url = urlreverse("ietf.ietfauth.views.profile")
|
||||||
login_url = urlreverse(ietf.ietfauth.views.login)
|
login_url = urlreverse("ietf.ietfauth.views.login")
|
||||||
redir_url = '%s?next=%s' % (login_url, chun_url)
|
redir_url = '%s?next=%s' % (login_url, chun_url)
|
||||||
|
|
||||||
# get without logging in
|
# get without logging in
|
||||||
|
@ -856,9 +854,6 @@ class IetfAuthTests(TestCase):
|
||||||
key2.delete()
|
key2.delete()
|
||||||
|
|
||||||
def test_send_apikey_report(self):
|
def test_send_apikey_report(self):
|
||||||
from ietf.ietfauth.management.commands.send_apikey_usage_emails import Command
|
|
||||||
from ietf.utils.mail import outbox, empty_outbox
|
|
||||||
|
|
||||||
person = RoleFactory(name_id='secr', group__acronym='secretariat').person
|
person = RoleFactory(name_id='secr', group__acronym='secretariat').person
|
||||||
|
|
||||||
url = urlreverse('ietf.ietfauth.views.apikey_create')
|
url = urlreverse('ietf.ietfauth.views.apikey_create')
|
||||||
|
@ -883,9 +878,8 @@ class IetfAuthTests(TestCase):
|
||||||
date = str(date_today())
|
date = str(date_today())
|
||||||
|
|
||||||
empty_outbox()
|
empty_outbox()
|
||||||
cmd = Command()
|
send_apikey_usage_emails_task(days=7)
|
||||||
cmd.handle(verbosity=0, days=7)
|
|
||||||
|
|
||||||
self.assertEqual(len(outbox), len(endpoints))
|
self.assertEqual(len(outbox), len(endpoints))
|
||||||
for mail in outbox:
|
for mail in outbox:
|
||||||
body = get_payload_text(mail)
|
body = get_payload_text(mail)
|
||||||
|
|
|
@ -14,7 +14,7 @@ urlpatterns = [
|
||||||
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
|
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
|
||||||
url(r'^create/$', views.create_account),
|
url(r'^create/$', views.create_account),
|
||||||
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),
|
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),
|
||||||
url(r'^login/$', views.login),
|
url(r'^login/$', views.AnyEmailLoginView.as_view(), name="ietf.ietfauth.views.login"),
|
||||||
url(r'^logout/$', LogoutView.as_view(), name="django.contrib.auth.views.logout"),
|
url(r'^logout/$', LogoutView.as_view(), name="django.contrib.auth.views.logout"),
|
||||||
url(r'^password/$', views.change_password),
|
url(r'^password/$', views.change_password),
|
||||||
url(r'^profile/$', views.profile),
|
url(r'^profile/$', views.profile),
|
||||||
|
|
|
@ -45,7 +45,7 @@ import django.core.signing
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import update_session_auth_hash, logout, authenticate
|
from django.contrib.auth import logout, update_session_auth_hash
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.forms import AuthenticationForm
|
from django.contrib.auth.forms import AuthenticationForm
|
||||||
from django.contrib.auth.hashers import identify_hasher
|
from django.contrib.auth.hashers import identify_hasher
|
||||||
|
@ -752,53 +752,57 @@ def change_username(request):
|
||||||
return render(request, 'registration/change_username.html', {'form': form})
|
return render(request, 'registration/change_username.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
class AnyEmailAuthenticationForm(AuthenticationForm):
|
||||||
def login(request, extra_context=None):
|
"""AuthenticationForm that allows any email address as the username
|
||||||
"""
|
|
||||||
This login function is a wrapper around django's login() for the purpose
|
Also performs a check for a cleared password field and provides a helpful error message
|
||||||
of providing a notification if the user's password has been cleared. The
|
if that applies to the user attempting to log in.
|
||||||
warning will be triggered if the password field has been set to something
|
|
||||||
which is not recognized as a valid password hash.
|
|
||||||
"""
|
"""
|
||||||
|
_unauthenticated_user = None
|
||||||
|
|
||||||
if request.method == "POST":
|
def clean_username(self):
|
||||||
form = AuthenticationForm(request, data=request.POST)
|
username = self.cleaned_data.get("username", None)
|
||||||
username = form.data.get('username')
|
if username is None:
|
||||||
user = User.objects.filter(username__iexact=username).first() # Consider _never_ actually looking for the User username and only looking at Email
|
raise self.get_invalid_login_error()
|
||||||
if not user:
|
user = User.objects.filter(username__iexact=username).first()
|
||||||
# try to find user ID from the email address
|
if user is None:
|
||||||
email = Email.objects.filter(address=username).first()
|
email = Email.objects.filter(address=username).first()
|
||||||
if email and email.person and email.person.user:
|
if email and email.person:
|
||||||
u2 = email.person.user
|
user = email.person.user # might be None
|
||||||
# be conservative, only accept this if login is valid
|
if user is None:
|
||||||
if u2:
|
raise self.get_invalid_login_error()
|
||||||
pw = form.data.get('password')
|
self._unauthenticated_user = user # remember this for the clean() method
|
||||||
au = authenticate(request, username=u2.username, password=pw)
|
return user.username
|
||||||
if au:
|
|
||||||
# kludge to change the querydict
|
def clean(self):
|
||||||
q2 = request.POST.copy()
|
if self._unauthenticated_user is not None:
|
||||||
q2['username'] = u2.username
|
try:
|
||||||
request.POST = q2
|
identify_hasher(self._unauthenticated_user.password)
|
||||||
user = u2
|
|
||||||
#
|
|
||||||
if user:
|
|
||||||
try:
|
|
||||||
identify_hasher(user.password)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
extra_context = {"alert":
|
self.add_error(
|
||||||
"Note: Your password has been cleared because "
|
"password",
|
||||||
"of possible password leakage. "
|
'Your password has been cleared because of possible password leakage. '
|
||||||
"Please use the password reset link below "
|
'Please use the "Forgot your password?" button below to set a new password '
|
||||||
"to set a new password for your account.",
|
'for your account.',
|
||||||
}
|
)
|
||||||
response = LoginView.as_view(extra_context=extra_context)(request)
|
return super().clean()
|
||||||
if isinstance(response, HttpResponseRedirect) and user and user.is_authenticated:
|
|
||||||
try:
|
|
||||||
user.person
|
class AnyEmailLoginView(LoginView):
|
||||||
except Person.DoesNotExist:
|
"""LoginView that allows any email address as the username
|
||||||
logout(request)
|
|
||||||
response = render(request, 'registration/missing_person.html')
|
Redirects to the missing_person page instead of logging in if the user does not have a Person
|
||||||
return response
|
"""
|
||||||
|
form_class = AnyEmailAuthenticationForm
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""Security check complete. Log the user in if they have a Person."""
|
||||||
|
user = form.get_user() # user has authenticated at this point
|
||||||
|
if not hasattr(user, "person"):
|
||||||
|
logout(self.request) # should not be logged in yet, but just in case...
|
||||||
|
return render(self.request, "registration/missing_person.html")
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@person_required
|
@person_required
|
||||||
|
|
|
@ -32,7 +32,7 @@ from ietf.group.models import Group
|
||||||
from ietf.person.models import Email
|
from ietf.person.models import Email
|
||||||
from ietf.person.fields import SearchableEmailField
|
from ietf.person.fields import SearchableEmailField
|
||||||
from ietf.doc.models import Document
|
from ietf.doc.models import Document
|
||||||
from ietf.utils.fields import DatepickerDateField
|
from ietf.utils.fields import DatepickerDateField, ModelMultipleChoiceField
|
||||||
from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO
|
from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
|
@ -200,7 +200,7 @@ class SearchLiaisonForm(forms.Form):
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
class CustomModelMultipleChoiceField(ModelMultipleChoiceField):
|
||||||
'''If value is a QuerySet, return it as is (for use in widget.render)'''
|
'''If value is a QuerySet, return it as is (for use in widget.render)'''
|
||||||
def prepare_value(self, value):
|
def prepare_value(self, value):
|
||||||
if isinstance(value, QuerySetAny):
|
if isinstance(value, QuerySetAny):
|
||||||
|
@ -215,12 +215,12 @@ class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||||
class LiaisonModelForm(forms.ModelForm):
|
class LiaisonModelForm(forms.ModelForm):
|
||||||
'''Specify fields which require a custom widget or that are not part of the model.
|
'''Specify fields which require a custom widget or that are not part of the model.
|
||||||
'''
|
'''
|
||||||
from_groups = forms.ModelMultipleChoiceField(queryset=Group.objects.all(),label='Groups',required=False)
|
from_groups = ModelMultipleChoiceField(queryset=Group.objects.all(),label='Groups',required=False)
|
||||||
from_groups.widget.attrs["class"] = "select2-field"
|
from_groups.widget.attrs["class"] = "select2-field"
|
||||||
from_groups.widget.attrs['data-minimum-input-length'] = 0
|
from_groups.widget.attrs['data-minimum-input-length'] = 0
|
||||||
from_contact = forms.EmailField() # type: Union[forms.EmailField, SearchableEmailField]
|
from_contact = forms.EmailField() # type: Union[forms.EmailField, SearchableEmailField]
|
||||||
to_contacts = forms.CharField(label="Contacts", widget=forms.Textarea(attrs={'rows':'3', }), strip=False)
|
to_contacts = forms.CharField(label="Contacts", widget=forms.Textarea(attrs={'rows':'3', }), strip=False)
|
||||||
to_groups = forms.ModelMultipleChoiceField(queryset=Group.objects,label='Groups',required=False)
|
to_groups = ModelMultipleChoiceField(queryset=Group.objects,label='Groups',required=False)
|
||||||
to_groups.widget.attrs["class"] = "select2-field"
|
to_groups.widget.attrs["class"] = "select2-field"
|
||||||
to_groups.widget.attrs['data-minimum-input-length'] = 0
|
to_groups.widget.attrs['data-minimum-input-length'] = 0
|
||||||
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Deadline', required=True)
|
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Deadline', required=True)
|
||||||
|
|
|
@ -28,7 +28,13 @@ from ietf.meeting.helpers import is_interim_meeting_approved, get_next_agenda_na
|
||||||
from ietf.message.models import Message
|
from ietf.message.models import Message
|
||||||
from ietf.name.models import TimeSlotTypeName, SessionPurposeName
|
from ietf.name.models import TimeSlotTypeName, SessionPurposeName
|
||||||
from ietf.person.models import Person
|
from ietf.person.models import Person
|
||||||
from ietf.utils.fields import DatepickerDateField, DurationField, MultiEmailField, DatepickerSplitDateTimeWidget
|
from ietf.utils.fields import (
|
||||||
|
DatepickerDateField,
|
||||||
|
DatepickerSplitDateTimeWidget,
|
||||||
|
DurationField,
|
||||||
|
ModelMultipleChoiceField,
|
||||||
|
MultiEmailField,
|
||||||
|
)
|
||||||
from ietf.utils.validators import ( validate_file_size, validate_mime_type,
|
from ietf.utils.validators import ( validate_file_size, validate_mime_type,
|
||||||
validate_file_extension, validate_no_html_frame)
|
validate_file_extension, validate_no_html_frame)
|
||||||
|
|
||||||
|
@ -551,7 +557,7 @@ class SwapTimeslotsForm(forms.Form):
|
||||||
queryset=TimeSlot.objects.none(), # default to none, fill in when we have a meeting
|
queryset=TimeSlot.objects.none(), # default to none, fill in when we have a meeting
|
||||||
widget=forms.TextInput,
|
widget=forms.TextInput,
|
||||||
)
|
)
|
||||||
rooms = forms.ModelMultipleChoiceField(
|
rooms = ModelMultipleChoiceField(
|
||||||
required=True,
|
required=True,
|
||||||
queryset=Room.objects.none(), # default to none, fill in when we have a meeting
|
queryset=Room.objects.none(), # default to none, fill in when we have a meeting
|
||||||
widget=CsvModelPkInput,
|
widget=CsvModelPkInput,
|
||||||
|
@ -617,7 +623,7 @@ class TimeSlotCreateForm(forms.Form):
|
||||||
)
|
)
|
||||||
duration = TimeSlotDurationField()
|
duration = TimeSlotDurationField()
|
||||||
show_location = forms.BooleanField(required=False, initial=True)
|
show_location = forms.BooleanField(required=False, initial=True)
|
||||||
locations = forms.ModelMultipleChoiceField(
|
locations = ModelMultipleChoiceField(
|
||||||
queryset=Room.objects.none(),
|
queryset=Room.objects.none(),
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,6 +21,7 @@ from ietf.nomcom.utils import (NOMINATION_RECEIPT_TEMPLATE, FEEDBACK_RECEIPT_TEM
|
||||||
from ietf.person.models import Email
|
from ietf.person.models import Email
|
||||||
from ietf.person.fields import (SearchableEmailField, SearchableEmailsField,
|
from ietf.person.fields import (SearchableEmailField, SearchableEmailsField,
|
||||||
SearchablePersonField, SearchablePersonsField )
|
SearchablePersonField, SearchablePersonsField )
|
||||||
|
from ietf.utils.fields import ModelMultipleChoiceField
|
||||||
from ietf.utils.mail import send_mail
|
from ietf.utils.mail import send_mail
|
||||||
from ietf.mailtrigger.utils import gather_address_lists
|
from ietf.mailtrigger.utils import gather_address_lists
|
||||||
|
|
||||||
|
@ -719,9 +720,9 @@ class MutableFeedbackForm(forms.ModelForm):
|
||||||
required= self.feedback_type.slug != 'comment',
|
required= self.feedback_type.slug != 'comment',
|
||||||
help_text='Hold down "Control", or "Command" on a Mac, to select more than one.')
|
help_text='Hold down "Control", or "Command" on a Mac, to select more than one.')
|
||||||
if self.feedback_type.slug == 'comment':
|
if self.feedback_type.slug == 'comment':
|
||||||
self.fields['topic'] = forms.ModelMultipleChoiceField(queryset=self.nomcom.topic_set.all(),
|
self.fields['topic'] = ModelMultipleChoiceField(queryset=self.nomcom.topic_set.all(),
|
||||||
help_text='Hold down "Control" or "Command" on a Mac, to select more than one.',
|
help_text='Hold down "Control" or "Command" on a Mac, to select more than one.',
|
||||||
required=False,)
|
required=False,)
|
||||||
else:
|
else:
|
||||||
self.fields['position'] = forms.ModelChoiceField(queryset=Position.objects.get_by_nomcom(self.nomcom).filter(is_open=True), label="Position")
|
self.fields['position'] = forms.ModelChoiceField(queryset=Position.objects.get_by_nomcom(self.nomcom).filter(is_open=True), label="Position")
|
||||||
self.fields['searched_email'] = SearchableEmailField(only_users=False,help_text="Try to find the candidate you are classifying with this field first. Only use the name and email fields below if this search does not find the candidate.",label="Candidate",required=False)
|
self.fields['searched_email'] = SearchableEmailField(only_users=False,help_text="Try to find the candidate you are classifying with this field first. Only use the name and email fields below if this search does not find the candidate.",label="Candidate",required=False)
|
||||||
|
@ -847,7 +848,7 @@ class EditNomineeForm(forms.ModelForm):
|
||||||
class NominationResponseCommentForm(forms.Form):
|
class NominationResponseCommentForm(forms.Form):
|
||||||
comments = forms.CharField(widget=forms.Textarea,required=False,help_text="Any comments provided will be encrypted and will only be visible to the NomCom.", strip=False)
|
comments = forms.CharField(widget=forms.Textarea,required=False,help_text="Any comments provided will be encrypted and will only be visible to the NomCom.", strip=False)
|
||||||
|
|
||||||
class NomcomVolunteerMultipleChoiceField(forms.ModelMultipleChoiceField):
|
class NomcomVolunteerMultipleChoiceField(ModelMultipleChoiceField):
|
||||||
def label_from_instance(self, obj):
|
def label_from_instance(self, obj):
|
||||||
year = obj.year()
|
year = obj.year()
|
||||||
return f'Volunteer for the {year}/{year+1} Nominating Committee'
|
return f'Volunteer for the {year}/{year+1} Nominating Committee'
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
# Copyright The IETF Trust 2021, All Rights Reserved
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.db.models import Max, Min
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from ietf.person.models import PersonApiKeyEvent
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Purge PersonApiKeyEvent instances older than KEEP_DAYS days'
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument('keep_days', type=int,
|
|
||||||
help='Delete events older than this many days')
|
|
||||||
parser.add_argument('-n', '--dry-run', action='store_true', default=False,
|
|
||||||
help="Don't delete events, just show what would be done")
|
|
||||||
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
keep_days = options['keep_days']
|
|
||||||
dry_run = options['dry_run']
|
|
||||||
verbosity = options.get("verbosity", 1)
|
|
||||||
|
|
||||||
def _format_count(count, unit='day'):
|
|
||||||
return '{} {}{}'.format(count, unit, ('' if count == 1 else 's'))
|
|
||||||
|
|
||||||
if keep_days < 0:
|
|
||||||
raise CommandError('Negative keep_days not allowed ({} was specified)'.format(keep_days))
|
|
||||||
|
|
||||||
if verbosity > 1:
|
|
||||||
self.stdout.write('purge_old_personal_api_key_events: Finding events older than {}\n'.format(_format_count(keep_days)))
|
|
||||||
if dry_run:
|
|
||||||
self.stdout.write('Dry run requested, records will not be deleted\n')
|
|
||||||
self.stdout.flush()
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
old_events = PersonApiKeyEvent.objects.filter(
|
|
||||||
time__lt=now - timedelta(days=keep_days)
|
|
||||||
)
|
|
||||||
|
|
||||||
stats = old_events.aggregate(Min('time'), Max('time'))
|
|
||||||
old_count = old_events.count()
|
|
||||||
if old_count == 0:
|
|
||||||
if verbosity > 1:
|
|
||||||
self.stdout.write('No events older than {} found\n'.format(_format_count(keep_days)))
|
|
||||||
return
|
|
||||||
|
|
||||||
oldest_date = stats['time__min']
|
|
||||||
oldest_ago = now - oldest_date
|
|
||||||
newest_date = stats['time__max']
|
|
||||||
newest_ago = now - newest_date
|
|
||||||
|
|
||||||
action_fmt = 'Would delete {}\n' if dry_run else 'Deleting {}\n'
|
|
||||||
if verbosity > 1:
|
|
||||||
self.stdout.write(action_fmt.format(_format_count(old_count, 'event')))
|
|
||||||
self.stdout.write(' Oldest at {} ({} ago)\n'.format(oldest_date, _format_count(oldest_ago.days)))
|
|
||||||
self.stdout.write(' Most recent at {} ({} ago)\n'.format(newest_date, _format_count(newest_ago.days)))
|
|
||||||
self.stdout.flush()
|
|
||||||
|
|
||||||
if not dry_run:
|
|
||||||
old_events.delete()
|
|
|
@ -1,122 +0,0 @@
|
||||||
# Copyright The IETF Trust 2021, All Rights Reserved
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
from django.core.management import call_command, CommandError
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from ietf.person.factories import PersonApiKeyEventFactory
|
|
||||||
from ietf.person.models import PersonApiKeyEvent, PersonEvent
|
|
||||||
from ietf.utils.test_utils import TestCase
|
|
||||||
|
|
||||||
|
|
||||||
class CommandTests(TestCase):
|
|
||||||
@staticmethod
|
|
||||||
def _call_command(command_name, *args, **options):
|
|
||||||
out = StringIO()
|
|
||||||
options['stdout'] = out
|
|
||||||
call_command(command_name, *args, **options)
|
|
||||||
return out.getvalue()
|
|
||||||
|
|
||||||
def _assert_purge_results(self, cmd_output, expected_delete_count, expected_kept_events):
|
|
||||||
self.assertNotIn('Dry run requested', cmd_output)
|
|
||||||
if expected_delete_count == 0:
|
|
||||||
delete_text = 'No events older than'
|
|
||||||
else:
|
|
||||||
delete_text = 'Deleting {} event'.format(expected_delete_count)
|
|
||||||
self.assertIn(delete_text, cmd_output)
|
|
||||||
self.assertCountEqual(
|
|
||||||
PersonApiKeyEvent.objects.all(),
|
|
||||||
expected_kept_events,
|
|
||||||
'Wrong events were deleted'
|
|
||||||
)
|
|
||||||
|
|
||||||
def _assert_purge_dry_run_results(self, cmd_output, expected_delete_count, expected_kept_events):
|
|
||||||
self.assertIn('Dry run requested', cmd_output)
|
|
||||||
if expected_delete_count == 0:
|
|
||||||
delete_text = 'No events older than'
|
|
||||||
else:
|
|
||||||
delete_text = 'Would delete {} event'.format(expected_delete_count)
|
|
||||||
self.assertIn(delete_text, cmd_output)
|
|
||||||
self.assertCountEqual(
|
|
||||||
PersonApiKeyEvent.objects.all(),
|
|
||||||
expected_kept_events,
|
|
||||||
'Events were deleted when dry-run option was used'
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_purge_old_personal_api_key_events(self):
|
|
||||||
keep_days = 10
|
|
||||||
|
|
||||||
# Remember how many PersonEvents were present so we can verify they're cleaned up properly.
|
|
||||||
personevents_before = PersonEvent.objects.count()
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
# The first of these events will be timestamped a fraction of a second more than keep_days
|
|
||||||
# days ago by the time we call the management command, so will just barely chosen for purge.
|
|
||||||
old_events = [
|
|
||||||
PersonApiKeyEventFactory(time=now - datetime.timedelta(days=n))
|
|
||||||
for n in range(keep_days, 2 * keep_days + 1)
|
|
||||||
]
|
|
||||||
num_old_events = len(old_events)
|
|
||||||
|
|
||||||
recent_events = [
|
|
||||||
PersonApiKeyEventFactory(time=now - datetime.timedelta(days=n))
|
|
||||||
for n in range(0, keep_days)
|
|
||||||
]
|
|
||||||
# We did not create recent_event timestamped exactly keep_days ago because it would
|
|
||||||
# be treated as an old_event by the management command. Create an event a few seconds
|
|
||||||
# on the "recent" side of keep_days old to test the threshold.
|
|
||||||
recent_events.append(
|
|
||||||
PersonApiKeyEventFactory(
|
|
||||||
time=now + datetime.timedelta(seconds=3) - datetime.timedelta(days=keep_days)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
num_recent_events = len(recent_events)
|
|
||||||
|
|
||||||
# call with dry run
|
|
||||||
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '--dry-run', '-v2')
|
|
||||||
self._assert_purge_dry_run_results(output, num_old_events, old_events + recent_events)
|
|
||||||
|
|
||||||
# call for real
|
|
||||||
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '-v2')
|
|
||||||
self._assert_purge_results(output, num_old_events, recent_events)
|
|
||||||
self.assertEqual(PersonEvent.objects.count(), personevents_before + num_recent_events,
|
|
||||||
'PersonEvents were not cleaned up properly')
|
|
||||||
|
|
||||||
# repeat - there should be nothing left to delete
|
|
||||||
output = self._call_command('purge_old_personal_api_key_events', '--dry-run', str(keep_days), '-v2')
|
|
||||||
self._assert_purge_dry_run_results(output, 0, recent_events)
|
|
||||||
|
|
||||||
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '-v2')
|
|
||||||
self._assert_purge_results(output, 0, recent_events)
|
|
||||||
self.assertEqual(PersonEvent.objects.count(), personevents_before + num_recent_events,
|
|
||||||
'PersonEvents were not cleaned up properly')
|
|
||||||
|
|
||||||
# and now delete the remaining events
|
|
||||||
output = self._call_command('purge_old_personal_api_key_events', '0', '-v2')
|
|
||||||
self._assert_purge_results(output, num_recent_events, [])
|
|
||||||
self.assertEqual(PersonEvent.objects.count(), personevents_before,
|
|
||||||
'PersonEvents were not cleaned up properly')
|
|
||||||
|
|
||||||
def test_purge_old_personal_api_key_events_rejects_invalid_arguments(self):
|
|
||||||
"""The purge_old_personal_api_key_events command should reject invalid arguments"""
|
|
||||||
event = PersonApiKeyEventFactory(time=timezone.now() - datetime.timedelta(days=30))
|
|
||||||
|
|
||||||
with self.assertRaises(CommandError):
|
|
||||||
self._call_command('purge_old_personal_api_key_events')
|
|
||||||
|
|
||||||
with self.assertRaises(CommandError):
|
|
||||||
self._call_command('purge_old_personal_api_key_events', '-15')
|
|
||||||
|
|
||||||
with self.assertRaises(CommandError):
|
|
||||||
self._call_command('purge_old_personal_api_key_events', '15.3')
|
|
||||||
|
|
||||||
with self.assertRaises(CommandError):
|
|
||||||
self._call_command('purge_old_personal_api_key_events', '15', '15')
|
|
||||||
|
|
||||||
with self.assertRaises(CommandError):
|
|
||||||
self._call_command('purge_old_personal_api_key_events', 'abc', '15')
|
|
||||||
|
|
||||||
self.assertCountEqual(PersonApiKeyEvent.objects.all(), [event])
|
|
59
ietf/person/tasks.py
Normal file
59
ietf/person/tasks.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
# Copyright The IETF Trust 2024, All Rights Reserved
|
||||||
|
#
|
||||||
|
# Celery task definitions
|
||||||
|
#
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ietf.utils import log
|
||||||
|
from ietf.utils.mail import send_mail
|
||||||
|
from .models import PersonalApiKey, PersonApiKeyEvent
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_apikey_usage_emails_task(days):
|
||||||
|
"""Send usage emails to Persons who have API keys"""
|
||||||
|
earliest = timezone.now() - datetime.timedelta(days=days)
|
||||||
|
keys = PersonalApiKey.objects.filter(
|
||||||
|
valid=True,
|
||||||
|
personapikeyevent__time__gt=earliest,
|
||||||
|
).distinct()
|
||||||
|
for key in keys:
|
||||||
|
events = PersonApiKeyEvent.objects.filter(key=key, time__gt=earliest)
|
||||||
|
count = events.count()
|
||||||
|
events = events[:32]
|
||||||
|
if count:
|
||||||
|
key_name = key.hash()[:8]
|
||||||
|
subject = "API key usage for key '%s' for the last %s days" % (
|
||||||
|
key_name,
|
||||||
|
days,
|
||||||
|
)
|
||||||
|
to = key.person.email_address()
|
||||||
|
frm = settings.DEFAULT_FROM_EMAIL
|
||||||
|
send_mail(
|
||||||
|
None,
|
||||||
|
to,
|
||||||
|
frm,
|
||||||
|
subject,
|
||||||
|
"utils/apikey_usage_report.txt",
|
||||||
|
{
|
||||||
|
"person": key.person,
|
||||||
|
"days": days,
|
||||||
|
"key": key,
|
||||||
|
"key_name": key_name,
|
||||||
|
"count": count,
|
||||||
|
"events": events,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def purge_personal_api_key_events_task(keep_days):
|
||||||
|
keep_since = timezone.now() - datetime.timedelta(days=keep_days)
|
||||||
|
old_events = PersonApiKeyEvent.objects.filter(time__lt=keep_since)
|
||||||
|
count = len(old_events)
|
||||||
|
old_events.delete()
|
||||||
|
log.log(f"Deleted {count} PersonApiKeyEvents older than {keep_since}")
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
import mock
|
||||||
|
|
||||||
from io import StringIO, BytesIO
|
from io import StringIO, BytesIO
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
@ -25,8 +26,9 @@ from ietf.group.models import Group
|
||||||
from ietf.nomcom.models import NomCom
|
from ietf.nomcom.models import NomCom
|
||||||
from ietf.nomcom.test_data import nomcom_test_data
|
from ietf.nomcom.test_data import nomcom_test_data
|
||||||
from ietf.nomcom.factories import NomComFactory, NomineeFactory, NominationFactory, FeedbackFactory, PositionFactory
|
from ietf.nomcom.factories import NomComFactory, NomineeFactory, NominationFactory, FeedbackFactory, PositionFactory
|
||||||
from ietf.person.factories import EmailFactory, PersonFactory
|
from ietf.person.factories import EmailFactory, PersonFactory, PersonApiKeyEventFactory
|
||||||
from ietf.person.models import Person, Alias
|
from ietf.person.models import Person, Alias, PersonApiKeyEvent
|
||||||
|
from ietf.person.tasks import purge_personal_api_key_events_task
|
||||||
from ietf.person.utils import (merge_persons, determine_merge_order, send_merge_notification,
|
from ietf.person.utils import (merge_persons, determine_merge_order, send_merge_notification,
|
||||||
handle_users, get_extra_primary, dedupe_aliases, move_related_objects, merge_nominees,
|
handle_users, get_extra_primary, dedupe_aliases, move_related_objects, merge_nominees,
|
||||||
handle_reviewer_settings, get_dots)
|
handle_reviewer_settings, get_dots)
|
||||||
|
@ -450,3 +452,16 @@ class PersonUtilsTests(TestCase):
|
||||||
self.assertEqual(get_dots(ncmember),['nomcom'])
|
self.assertEqual(get_dots(ncmember),['nomcom'])
|
||||||
ncchair = RoleFactory(group__acronym='nomcom2020',group__type_id='nomcom',name_id='chair').person
|
ncchair = RoleFactory(group__acronym='nomcom2020',group__type_id='nomcom',name_id='chair').person
|
||||||
self.assertEqual(get_dots(ncchair),['nomcom'])
|
self.assertEqual(get_dots(ncchair),['nomcom'])
|
||||||
|
|
||||||
|
|
||||||
|
class TaskTests(TestCase):
|
||||||
|
@mock.patch("ietf.person.tasks.log.log")
|
||||||
|
def test_purge_personal_api_key_events_task(self, mock_log):
|
||||||
|
now = timezone.now()
|
||||||
|
old_event = PersonApiKeyEventFactory(time=now - datetime.timedelta(days=1, minutes=1))
|
||||||
|
young_event = PersonApiKeyEventFactory(time=now - datetime.timedelta(days=1, minutes=-1))
|
||||||
|
purge_personal_api_key_events_task(keep_days=1)
|
||||||
|
self.assertFalse(PersonApiKeyEvent.objects.filter(pk=old_event.pk).exists())
|
||||||
|
self.assertTrue(PersonApiKeyEvent.objects.filter(pk=young_event.pk).exists())
|
||||||
|
self.assertTrue(mock_log.called)
|
||||||
|
self.assertIn("Deleted 1", mock_log.call_args[0][0])
|
||||||
|
|
|
@ -13,6 +13,7 @@ from ietf.meeting.forms import sessiondetailsformset_factory
|
||||||
from ietf.meeting.models import ResourceAssociation, Constraint
|
from ietf.meeting.models import ResourceAssociation, Constraint
|
||||||
from ietf.person.fields import SearchablePersonsField
|
from ietf.person.fields import SearchablePersonsField
|
||||||
from ietf.person.models import Person
|
from ietf.person.models import Person
|
||||||
|
from ietf.utils.fields import ModelMultipleChoiceField
|
||||||
from ietf.utils.html import clean_text_field
|
from ietf.utils.html import clean_text_field
|
||||||
from ietf.utils import log
|
from ietf.utils import log
|
||||||
|
|
||||||
|
@ -57,7 +58,7 @@ class GroupSelectForm(forms.Form):
|
||||||
self.fields['group'].widget.choices = choices
|
self.fields['group'].widget.choices = choices
|
||||||
|
|
||||||
|
|
||||||
class NameModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
class NameModelMultipleChoiceField(ModelMultipleChoiceField):
|
||||||
def label_from_instance(self, name):
|
def label_from_instance(self, name):
|
||||||
return name.desc
|
return name.desc
|
||||||
|
|
||||||
|
@ -159,7 +160,7 @@ class SessionForm(forms.Form):
|
||||||
self.fields['resources'].widget = forms.MultipleHiddenInput()
|
self.fields['resources'].widget = forms.MultipleHiddenInput()
|
||||||
self.fields['timeranges'].widget = forms.MultipleHiddenInput()
|
self.fields['timeranges'].widget = forms.MultipleHiddenInput()
|
||||||
# and entirely replace bethere - no need to support searching if input is hidden
|
# and entirely replace bethere - no need to support searching if input is hidden
|
||||||
self.fields['bethere'] = forms.ModelMultipleChoiceField(
|
self.fields['bethere'] = ModelMultipleChoiceField(
|
||||||
widget=forms.MultipleHiddenInput, required=False,
|
widget=forms.MultipleHiddenInput, required=False,
|
||||||
queryset=Person.objects.all(),
|
queryset=Person.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
|
@ -817,6 +817,9 @@ IDSUBMIT_CHECKER_CLASSES = (
|
||||||
# Max time to allow for validation before a submission is subject to cancellation
|
# Max time to allow for validation before a submission is subject to cancellation
|
||||||
IDSUBMIT_MAX_VALIDATION_TIME = datetime.timedelta(minutes=20)
|
IDSUBMIT_MAX_VALIDATION_TIME = datetime.timedelta(minutes=20)
|
||||||
|
|
||||||
|
# Age at which a submission expires if not posted
|
||||||
|
IDSUBMIT_EXPIRATION_AGE = datetime.timedelta(days=14)
|
||||||
|
|
||||||
IDSUBMIT_MANUAL_STAGING_DIR = '/tmp/'
|
IDSUBMIT_MANUAL_STAGING_DIR = '/tmp/'
|
||||||
|
|
||||||
IDSUBMIT_FILE_TYPES = (
|
IDSUBMIT_FILE_TYPES = (
|
||||||
|
|
|
@ -39,6 +39,7 @@ from ietf.submit.parsers.plain_parser import PlainParser
|
||||||
from ietf.submit.parsers.xml_parser import XMLParser
|
from ietf.submit.parsers.xml_parser import XMLParser
|
||||||
from ietf.utils import log
|
from ietf.utils import log
|
||||||
from ietf.utils.draft import PlaintextDraft
|
from ietf.utils.draft import PlaintextDraft
|
||||||
|
from ietf.utils.fields import ModelMultipleChoiceField
|
||||||
from ietf.utils.text import normalize_text
|
from ietf.utils.text import normalize_text
|
||||||
from ietf.utils.timezone import date_today
|
from ietf.utils.timezone import date_today
|
||||||
from ietf.utils.xmldraft import InvalidXMLError, XMLDraft, XMLParseError
|
from ietf.utils.xmldraft import InvalidXMLError, XMLDraft, XMLParseError
|
||||||
|
@ -793,7 +794,7 @@ class EditSubmissionForm(forms.ModelForm):
|
||||||
rev = forms.CharField(label='Revision', max_length=2, required=True)
|
rev = forms.CharField(label='Revision', max_length=2, required=True)
|
||||||
document_date = forms.DateField(required=True)
|
document_date = forms.DateField(required=True)
|
||||||
pages = forms.IntegerField(required=True)
|
pages = forms.IntegerField(required=True)
|
||||||
formal_languages = forms.ModelMultipleChoiceField(queryset=FormalLanguageName.objects.filter(used=True), widget=forms.CheckboxSelectMultiple, required=False)
|
formal_languages = ModelMultipleChoiceField(queryset=FormalLanguageName.objects.filter(used=True), widget=forms.CheckboxSelectMultiple, required=False)
|
||||||
abstract = forms.CharField(widget=forms.Textarea, required=True, strip=False)
|
abstract = forms.CharField(widget=forms.Textarea, required=True, strip=False)
|
||||||
|
|
||||||
note = forms.CharField(label=mark_safe('Comment to the Secretariat'), widget=forms.Textarea, required=False, strip=False)
|
note = forms.CharField(label=mark_safe('Comment to the Secretariat'), widget=forms.Textarea, required=False, strip=False)
|
||||||
|
|
|
@ -37,19 +37,34 @@ def process_and_accept_uploaded_submission_task(submission_id):
|
||||||
@shared_task
|
@shared_task
|
||||||
def cancel_stale_submissions():
|
def cancel_stale_submissions():
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
stale_submissions = Submission.objects.filter(
|
# first check for submissions gone stale awaiting validation
|
||||||
|
stale_unvalidated_submissions = Submission.objects.filter(
|
||||||
state_id='validating',
|
state_id='validating',
|
||||||
).annotate(
|
).annotate(
|
||||||
submitted_at=Min('submissionevent__time'),
|
submitted_at=Min('submissionevent__time'),
|
||||||
).filter(
|
).filter(
|
||||||
submitted_at__lt=now - settings.IDSUBMIT_MAX_VALIDATION_TIME,
|
submitted_at__lt=now - settings.IDSUBMIT_MAX_VALIDATION_TIME,
|
||||||
)
|
)
|
||||||
for subm in stale_submissions:
|
for subm in stale_unvalidated_submissions:
|
||||||
age = now - subm.submitted_at
|
age = now - subm.submitted_at
|
||||||
log.log(f'Canceling stale submission (id={subm.id}, age={age})')
|
log.log(f'Canceling stale submission (id={subm.id}, age={age})')
|
||||||
cancel_submission(subm)
|
cancel_submission(subm)
|
||||||
create_submission_event(None, subm, 'Submission canceled: validation checks took too long')
|
create_submission_event(None, subm, 'Submission canceled: validation checks took too long')
|
||||||
|
|
||||||
|
# now check for expired submissions
|
||||||
|
expired_submissions = Submission.objects.exclude(
|
||||||
|
state_id__in=["posted", "cancel"],
|
||||||
|
).annotate(
|
||||||
|
submitted_at=Min("submissionevent__time"),
|
||||||
|
).filter(
|
||||||
|
submitted_at__lt=now - settings.IDSUBMIT_EXPIRATION_AGE,
|
||||||
|
)
|
||||||
|
for subm in expired_submissions:
|
||||||
|
age = now - subm.submitted_at
|
||||||
|
log.log(f'Canceling expired submission (id={subm.id}, age={age})')
|
||||||
|
cancel_submission(subm)
|
||||||
|
create_submission_event(None, subm, 'Submission canceled: expired without being posted')
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True)
|
@shared_task(bind=True)
|
||||||
def poke(self):
|
def poke(self):
|
||||||
|
|
|
@ -42,7 +42,7 @@ from ietf.group.models import Group
|
||||||
from ietf.group.utils import setup_default_community_list_for_group
|
from ietf.group.utils import setup_default_community_list_for_group
|
||||||
from ietf.meeting.models import Meeting
|
from ietf.meeting.models import Meeting
|
||||||
from ietf.meeting.factories import MeetingFactory
|
from ietf.meeting.factories import MeetingFactory
|
||||||
from ietf.name.models import FormalLanguageName
|
from ietf.name.models import DraftSubmissionStateName, FormalLanguageName
|
||||||
from ietf.person.models import Person
|
from ietf.person.models import Person
|
||||||
from ietf.person.factories import UserFactory, PersonFactory, EmailFactory
|
from ietf.person.factories import UserFactory, PersonFactory, EmailFactory
|
||||||
from ietf.submit.factories import SubmissionFactory, SubmissionExtResourceFactory
|
from ietf.submit.factories import SubmissionFactory, SubmissionExtResourceFactory
|
||||||
|
@ -3136,28 +3136,59 @@ class AsyncSubmissionTests(BaseSubmitTestCase):
|
||||||
self.assertContains(r, s.name)
|
self.assertContains(r, s.name)
|
||||||
self.assertContains(r, 'This submission is being processed and validated.', status_code=200)
|
self.assertContains(r, 'This submission is being processed and validated.', status_code=200)
|
||||||
|
|
||||||
@override_settings(IDSUBMIT_MAX_VALIDATION_TIME=datetime.timedelta(minutes=30))
|
@override_settings(
|
||||||
|
IDSUBMIT_MAX_VALIDATION_TIME=datetime.timedelta(minutes=30),
|
||||||
|
IDSUBMIT_EXPIRATION_AGE=datetime.timedelta(minutes=90),
|
||||||
|
)
|
||||||
def test_cancel_stale_submissions(self):
|
def test_cancel_stale_submissions(self):
|
||||||
|
# these will be lists of (Submission, "state_id") pairs
|
||||||
|
submissions_to_skip = []
|
||||||
|
submissions_to_cancel = []
|
||||||
|
|
||||||
|
# submissions in the validating state
|
||||||
fresh_submission = SubmissionFactory(state_id='validating')
|
fresh_submission = SubmissionFactory(state_id='validating')
|
||||||
fresh_submission.submissionevent_set.create(
|
fresh_submission.submissionevent_set.create(
|
||||||
desc='fake created event',
|
desc='fake created event',
|
||||||
time=timezone.now() - datetime.timedelta(minutes=15),
|
time=timezone.now() - datetime.timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
submissions_to_skip.append((fresh_submission, "validating"))
|
||||||
|
|
||||||
stale_submission = SubmissionFactory(state_id='validating')
|
stale_submission = SubmissionFactory(state_id='validating')
|
||||||
stale_submission.submissionevent_set.create(
|
stale_submission.submissionevent_set.create(
|
||||||
desc='fake created event',
|
desc='fake created event',
|
||||||
time=timezone.now() - datetime.timedelta(minutes=30, seconds=1),
|
time=timezone.now() - datetime.timedelta(minutes=30, seconds=1),
|
||||||
)
|
)
|
||||||
|
submissions_to_cancel.append((stale_submission, "validating"))
|
||||||
|
|
||||||
|
# submissions in other states
|
||||||
|
for state in DraftSubmissionStateName.objects.filter(used=True).exclude(slug="validating"):
|
||||||
|
to_skip = SubmissionFactory(state_id=state.pk)
|
||||||
|
to_skip.submissionevent_set.create(
|
||||||
|
desc="fake created event",
|
||||||
|
time=timezone.now() - datetime.timedelta(minutes=45), # would be canceled if it were "validating"
|
||||||
|
)
|
||||||
|
submissions_to_skip.append((to_skip, state.pk))
|
||||||
|
to_expire = SubmissionFactory(state_id=state.pk)
|
||||||
|
to_expire.submissionevent_set.create(
|
||||||
|
desc="fake created event",
|
||||||
|
time=timezone.now() - datetime.timedelta(minutes=90, seconds=1),
|
||||||
|
)
|
||||||
|
if state.pk in ["posted", "cancel"]:
|
||||||
|
submissions_to_skip.append((to_expire, state.pk)) # these ones should not be expired regardless of age
|
||||||
|
else:
|
||||||
|
submissions_to_cancel.append(((to_expire, state.pk)))
|
||||||
|
|
||||||
cancel_stale_submissions()
|
cancel_stale_submissions()
|
||||||
|
|
||||||
fresh_submission = Submission.objects.get(pk=fresh_submission.pk)
|
for _subm, original_state_id in submissions_to_skip:
|
||||||
self.assertEqual(fresh_submission.state_id, 'validating')
|
subm = Submission.objects.get(pk=_subm.pk)
|
||||||
self.assertEqual(fresh_submission.submissionevent_set.count(), 1)
|
self.assertEqual(subm.state_id, original_state_id)
|
||||||
|
self.assertEqual(subm.submissionevent_set.count(), 1)
|
||||||
|
|
||||||
stale_submission = Submission.objects.get(pk=stale_submission.pk)
|
for _subm, _ in submissions_to_cancel:
|
||||||
self.assertEqual(stale_submission.state_id, 'cancel')
|
subm = Submission.objects.get(pk=_subm.pk)
|
||||||
self.assertEqual(stale_submission.submissionevent_set.count(), 2)
|
self.assertEqual(subm.state_id, "cancel")
|
||||||
|
self.assertEqual(subm.submissionevent_set.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class ApiSubmitTests(BaseSubmitTestCase):
|
class ApiSubmitTests(BaseSubmitTestCase):
|
||||||
|
|
|
@ -661,7 +661,7 @@ def move_files_to_repository(submission):
|
||||||
os.link(dest, ftp_dest)
|
os.link(dest, ftp_dest)
|
||||||
elif dest.exists():
|
elif dest.exists():
|
||||||
log.log("Intended to move '%s' to '%s', but found source missing while destination exists.")
|
log.log("Intended to move '%s' to '%s', but found source missing while destination exists.")
|
||||||
elif ext in submission.file_types.split(','):
|
elif f".{ext}" in submission.file_types.split(','):
|
||||||
raise ValueError("Intended to move '%s' to '%s', but found source and destination missing.")
|
raise ValueError("Intended to move '%s' to '%s', but found source and destination missing.")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -15,11 +15,16 @@
|
||||||
{% bootstrap_form ballot_writeup_form %}
|
{% bootstrap_form ballot_writeup_form %}
|
||||||
<div class="form-text my-3">
|
<div class="form-text my-3">
|
||||||
Technical summary, Working Group summary, document quality, personnel, IANA note. This text will be appended to all announcements and messages to the IRTF or RFC Editor.
|
Technical summary, Working Group summary, document quality, personnel, IANA note. This text will be appended to all announcements and messages to the IRTF or RFC Editor.
|
||||||
{% if ballot_issue_danger %}
|
{% if warn_lc %}
|
||||||
<p class="text-danger">
|
<p class="text-danger">
|
||||||
This document has not completed IETF Last Call. Please do not issue the ballot early without good reason.
|
This document has not completed IETF Last Call. Please do not issue the ballot early without good reason.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if warn_unexpected_state %}
|
||||||
|
<p class="text-danger">
|
||||||
|
This document is in an IESG state of "{{warn_unexpected_state}}". It would be unexpected to issue a ballot while in this state.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
|
|
@ -39,7 +39,11 @@
|
||||||
<a href="{% url 'ietf.meeting.views.session_details' num=meeting.number acronym=meeting.responsible_group.acronym %}">{{ meeting.number }}</a>
|
<a href="{% url 'ietf.meeting.views.session_details' num=meeting.number acronym=meeting.responsible_group.acronym %}">{{ meeting.number }}</a>
|
||||||
{% if meeting.interim_meeting_cancelled %}<span class="badge rounded-pill text-bg-warning">Cancelled</span>{% endif %}
|
{% if meeting.interim_meeting_cancelled %}<span class="badge rounded-pill text-bg-warning">Cancelled</span>{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'agenda' num=meeting.number %}">IETF-{{ meeting.number }}</a>
|
{% if meeting.get_number > 28 %}
|
||||||
|
<a href="{% url 'agenda' num=meeting.number %}">IETF-{{ meeting.number }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="https://www.ietf.org/proceedings/{{meeting.get_number|stringformat:'02d'}}.pdf">IETF-{{ meeting.number }}</a>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-sm-table-cell">
|
<td class="d-none d-sm-table-cell">
|
||||||
|
|
|
@ -137,8 +137,8 @@
|
||||||
<tr data-name="{{ pres.document.name }}">
|
<tr data-name="{{ pres.document.name }}">
|
||||||
{% url 'ietf.doc.views_doc.document_main' name=pres.document.name as url %}
|
{% url 'ietf.doc.views_doc.document_main' name=pres.document.name as url %}
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ pres.document.get_href }}">{{ pres.document.title }}</a>
|
<a href="{{ url }}">{{ pres.document.title }}</a>
|
||||||
<a href="{{ url }}">({{ pres.document.name }})</a>
|
<a href="{{ pres.document.get_href }}">( as json )</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -14,7 +14,7 @@ from typing import Optional, Type # pyflakes:ignore
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db import models # pyflakes:ignore
|
from django.db import models # pyflakes:ignore
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import ProhibitNullCharactersValidator, validate_email
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.dateparse import parse_duration
|
from django.utils.dateparse import parse_duration
|
||||||
|
|
||||||
|
@ -353,3 +353,20 @@ class MissingOkImageField(models.ImageField):
|
||||||
super().update_dimension_fields(*args, **kwargs)
|
super().update_dimension_fields(*args, **kwargs)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass # don't do anything if the file has gone missing
|
pass # don't do anything if the file has gone missing
|
||||||
|
|
||||||
|
|
||||||
|
class ModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||||
|
"""ModelMultipleChoiceField that rejects null characters cleanly"""
|
||||||
|
validate_no_nulls = ProhibitNullCharactersValidator()
|
||||||
|
|
||||||
|
def clean(self, value):
|
||||||
|
try:
|
||||||
|
for item in value:
|
||||||
|
self.validate_no_nulls(item)
|
||||||
|
except TypeError:
|
||||||
|
# A TypeError probably means value is not iterable, which most commonly comes up
|
||||||
|
# with None as a value. If it's something more exotic, we don't know how to test
|
||||||
|
# for null characters anyway. Either way, trust the superclass clean() method to
|
||||||
|
# handle it.
|
||||||
|
pass
|
||||||
|
return super().clean(value)
|
||||||
|
|
|
@ -241,6 +241,28 @@ class Command(BaseCommand):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PeriodicTask.objects.get_or_create(
|
||||||
|
name="Send personal API key usage emails",
|
||||||
|
task="ietf.person.tasks.send_apikey_usage_emails_task",
|
||||||
|
kwargs=json.dumps(dict(days=7)),
|
||||||
|
defaults=dict(
|
||||||
|
enabled=False,
|
||||||
|
crontab=self.crontabs["weekly"],
|
||||||
|
description="Send personal API key usage summary emails for the past week",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
PeriodicTask.objects.get_or_create(
|
||||||
|
name="Purge old personal API key events",
|
||||||
|
task="ietf.person.tasks.purge_personal_api_key_events_task",
|
||||||
|
kwargs=json.dumps(dict(keep_days=14)),
|
||||||
|
defaults=dict(
|
||||||
|
enabled=False,
|
||||||
|
crontab=self.crontabs["daily"],
|
||||||
|
description="Purge PersonApiKeyEvent instances older than 14 days",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def show_tasks(self):
|
def show_tasks(self):
|
||||||
for label, crontab in self.crontabs.items():
|
for label, crontab in self.crontabs.items():
|
||||||
tasks = PeriodicTask.objects.filter(crontab=crontab).order_by(
|
tasks = PeriodicTask.objects.filter(crontab=crontab).order_by(
|
||||||
|
|
Loading…
Reference in a new issue