Repository: CTFd/CTFd (python)

Issue:
[Feature] Admin Team List - New Team
Admins should be able to create teams quickly for the admin team list page.

**TODO:**
- [x] MVP https://github.com/deckar01/CTFd/commit/c5df9994cbe61bc4815d71247eaa21ee5ed54d3c
- [ ] Fix inline style issue
- [ ] Add tests

![screen shot 2017-11-18 at 4 41 31 pm](https://user-images.githubusercontent.com/3108007/32985404-7a3b5ccc-cc7f-11e7-906f-9dcc20fc2dff.png)
![screen shot 2017-11-18 at 4 41 47 pm](https://user-images.githubusercontent.com/3108007/32985403-7a2946ae-cc7f-11e7-8fe5-fef5c83860a6.png)



Pull request description:
Admin Team List - New Team
Closes #469

 - Adds a "New Team" button to the admin "Teams" page
 - Reuses the update team modal for for creating new teams
 - Adds an API route to allow admins to create a new team
 - Adds unit tests to cover added python code

Patch:
diff --git a/CTFd/admin/teams.py b/CTFd/admin/teams.py
index 263ab5e5..a35dedf0 100644
--- a/CTFd/admin/teams.py
+++ b/CTFd/admin/teams.py
@@ -6,6 +6,8 @@ from sqlalchemy.sql import not_
 
 from CTFd import utils
 
+import re
+
 admin_teams = Blueprint('admin_teams', __name__)
 
 
@@ -45,6 +47,54 @@ def admin_teams_view(page):
     return render_template('admin/teams.html', teams=teams, pages=pages, curr_page=page)
 
 
+@admin_teams.route('/admin/team/new', methods=['POST'])
+@admins_only
+def admin_create_team():
+    name = request.form.get('name', None)
+    password = request.form.get('password', None)
+    email = request.form.get('email', None)
+    website = request.form.get('website', None)
+    affiliation = request.form.get('affiliation', None)
+    country = request.form.get('country', None)
+
+    errors = []
+
+    if not name:
+        errors.append('The team requires a name')
+    elif Teams.query.filter(Teams.name == name).first():
+        errors.append('That name is taken')
+
+    if not email:
+        errors.append('The team requires an email')
+    elif Teams.query.filter(Teams.email == email).first():
+        errors.append('That email is taken')
+
+    if email:
+        valid_email = re.match(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", email)
+        if not valid_email:
+            errors.append("That email address is invalid")
+
+    if not password:
+        errors.append('The team requires a password')
+
+    if website and (website.startswith('http://') or website.startswith('https://')) is False:
+        errors.append('Websites must start with http:// or https://')
+
+    if errors:
+        db.session.close()
+        return jsonify({'data': errors})
+
+    team = Teams(name, email, password)
+    team.website = website
+    team.affiliation = affiliation
+    team.country = country
+
+    db.session.add(team)
+    db.session.commit()
+    db.session.close()
+    return jsonify({'data': ['success']})
+
+
 @admin_teams.route('/admin/team/<int:teamid>', methods=['GET', 'POST'])
 @admins_only
 def admin_team(teamid):
@@ -93,6 +143,11 @@ def admin_team(teamid):
 
         errors = []
 
+        if email:
+            valid_email = re.match(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", email)
+            if not valid_email:
+                errors.append("That email address is invalid")
+
         name_used = Teams.query.filter(Teams.name == name).first()
         if name_used and int(name_used.id) != int(teamid):
             errors.append('That name is taken')
@@ -101,6 +156,9 @@ def admin_team(teamid):
         if email_used and int(email_used.id) != int(teamid):
             errors.append('That email is taken')
 
+        if website and (website.startswith('http://') or website.startswith('https://')) is False:
+            errors.append('Websites must start with http:// or https://')
+
         if errors:
             db.session.close()
             return jsonify({'data': errors})
diff --git a/CTFd/themes/admin/templates/teams.html b/CTFd/themes/admin/templates/teams.html
index d05bab8f..adc75469 100644
--- a/CTFd/themes/admin/templates/teams.html
+++ b/CTFd/themes/admin/templates/teams.html
@@ -10,7 +10,10 @@ input[type="checkbox"] { margin: 0px !important; position: relative; top: 5px; }
 
 {% block content %}
 <div class="row">
-	<h1>Teams</h1>
+	<h1>
+		Teams
+		<i class="fa fa-plus-circle create-team" role="button" data-toggle="tooltip" title="Create Team"></i>
+	</h1>
 	<div id="confirm" class="modal fade" tabindex="-1">
 		<div class="modal-dialog">
 			<div class="modal-content">
@@ -41,7 +44,7 @@ input[type="checkbox"] { margin: 0px !important; position: relative; top: 5px; }
 			<div class="modal-content">
 				<div class="modal-header">
 					<button type="button" class="close" data-dismiss="modal">&times;</button>
-					<h2 class="text-center">Email User</h2>
+					<h2 class="text-center">Email Team</h2>
 				</div>
 				<div class="modal-body">
 					<form method="POST">
@@ -61,9 +64,9 @@ input[type="checkbox"] { margin: 0px !important; position: relative; top: 5px; }
 			<div class="modal-content">
 				<div class="modal-header">
 					<button type="button" class="close" data-dismiss="modal">&times;</button>
-					<h2 class="text-center">Edit User</h2>
+					<h2 class="text-center modal-action">Edit Team</h2>
 				</div>
-				<div class="modal-body" style="padding:20px; height:525px;">
+				<div class="modal-body clearfix" style="padding:20px;">
 					<form method="POST" action="{{ request.script_root }}/admin/teams/">
 						<input type="hidden" name="nonce" value="{{ nonce }}">
 						<input type="hidden" name="id">
@@ -94,7 +97,7 @@ input[type="checkbox"] { margin: 0px !important; position: relative; top: 5px; }
 						<div id="results">
 
 						</div>
-						<button id="update-user" type="submit" class="btn btn-theme btn-outlined pull-right">Update</button>
+						<button id="update-user" type="submit" class="btn btn-theme btn-outlined pull-right modal-action">Update</button>
 					</form>
 				</div>
 			</div>
@@ -158,8 +161,8 @@ input[type="checkbox"] { margin: 0px !important; position: relative; top: 5px; }
 				<td class="team-id" value="{{ team.id }}">{{ team.id }}</td>
 				<td class="team-name" value="{{ team.name }}"><a href="{{ request.script_root }}/admin/team/{{ team.id }}">{{ team.name | truncate(32) }}</a>
 				</td>
-				<td class="team-email">{{ team.email | truncate(32) }}</td>
-				<td class="team-website">{% if team.website and (team.website.startswith('http://') or team.website.startswith('https://')) %}<a href="{{ team.website }}">{{ team.website | truncate(32) }}</a>{% endif %}
+				<td class="team-email" value="{{ team.email }}">{{ team.email | truncate(32) }}</td>
+				<td class="team-website">{% if team.website %}<a href="{{ team.website }}">{{ team.website | truncate(32) }}</a>{% endif %}
 				</td>
 				<td class="team-affiliation" value="{{ team.affiliation if team.affiliation is not none }}"><span>{% if team.affiliation %}{{ team.affiliation | truncate(20) }}{% endif %}</span>
 				</td>
@@ -207,13 +210,22 @@ input[type="checkbox"] { margin: 0px !important; position: relative; top: 5px; }
 function load_update_modal(id, name, email, website, affiliation, country){
 	var modal_form = $('#user form');
 
-	modal_form.find('input[name=name]').val(name)
-	modal_form.find('input[name=id]').val(id)
-	modal_form.find('input[name=email]').val(email)
-	modal_form.find('input[name=website]').val(website)
-	modal_form.find('input[name=affiliation]').val(affiliation)
-	modal_form.find('input[name=country]').val(country)
-	$('#user form').attr('action', '{{ request.script_root }}/admin/team/'+id)
+	modal_form.find('input[name=name]').val(name);
+	modal_form.find('input[name=id]').val(id);
+	modal_form.find('input[name=email]').val(email);
+	modal_form.find('input[name=website]').val(website);
+	modal_form.find('input[name=affiliation]').val(affiliation);
+	modal_form.find('input[name=country]').val(country);
+	modal_form.find('input[name=password]').val('');
+
+	if (id == 'new'){
+		$('#user .modal-action').text('Create Team');
+	} else {
+		$('#user .modal-action').text('Edit Team');
+	}
+
+	$('#results').empty();
+	$('#user form').attr('action', '{{ request.script_root }}/admin/team/'+id);
 	$('#user').modal("show");
 }
 
@@ -221,28 +233,20 @@ $('#update-user').click(function(e){
 	e.preventDefault();
 	var id = $('#user input[name="id"]').val()
 	var user_data = $('#user form').serializeArray()
+	$('#results').empty();
 	$.post($('#user form').attr('action'), $('#user form').serialize(), function(data){
 		var data = $.parseJSON(JSON.stringify(data))
 		for (var i = 0; i < data['data'].length; i++) {
 			if (data['data'][i] == 'success'){
-				var row = $('tr[name='+id+']')
-				console.log($.grep(user_data, function(e){ return e.name == 'name'; })[0]['value'])
-				console.log(row.find('.team-name > a'))
-				row.find('.team-name > a').text( $.grep(user_data, function(e){ return e.name == 'name'; })[0]['value'] );
-				var new_email = $.grep(user_data, function(e){ return e.name == 'email'; })[0]['value'];
-				if (new_email){
-					row.find('.team-email').text( new_email );
-				}
-				row.find('.team-website > a').empty()
-				var website = $.grep(user_data, function(e){ return e.name == 'website'; })[0]['value']
-				row.find('.team-website').append($('<a>').attr('href', website).text(website));
-
-				row.find('.team-affiliation').text( $.grep(user_data, function(e){ return e.name == 'affiliation'; })[0]['value'] );
-				row.find('.team-country').text( $.grep(user_data, function(e){ return e.name == 'country'; })[0]['value'] );
 				$('#user').modal('hide');
+				location.reload();
 			}
 			else{
-				$('#results').append($('p').text( data['data'][i] ))
+				var error = $('<div class="alert alert-danger alert-dismissable">\n' +
+					'  <a href="#" class="close" data-dismiss="alert" aria-label="close">&times;</a>\n' +
+					'  {0}\n'.format(data['data'][i]) +
+					'</div>');
+				$('#results').append(error);
 			}
 		};
 	})
@@ -306,6 +310,10 @@ $('.fa-pencil-square-o').click(function(){
 	load_update_modal(id, name, email, website, affiliation, country);
 });
 
+$('.create-team').click(function(){
+	load_update_modal('new', '', '', '', '', '');
+});
+
 function load_confirm_modal(id, name){
 	var modal = $('#confirm')
 	modal.find('input[name=id]').val(id)


Test patch:
diff --git a/tests/admin/test_admin_facing.py b/tests/admin/test_admin_facing.py
index 514ade53..b8c1f57e 100644
--- a/tests/admin/test_admin_facing.py
+++ b/tests/admin/test_admin_facing.py
@@ -235,3 +235,138 @@ def test_admin_chal_detail_returns_proper_data():
         assert data == response
 
     destroy_ctfd(app)
+
+
+def test_admins_can_create_teams():
+    '''Test that admins can create new teams'''
+    app = create_ctfd()
+    with app.app_context():
+        client = login_as_user(app, name="admin", password="password")
+
+        with client.session_transaction() as sess:
+            data = {
+                'name': 'TunnelBunnies',
+                'password': 'fUnn3lJuNK135',
+                'email': 'scary.hares@trace.us',
+                'website': 'https://scary-hares.trace.us/',
+                'affiliation': 'Energizer',
+                'country': 'USA',
+                'nonce': sess.get('nonce'),
+            }
+            r = client.post('/admin/team/new', data=data)
+            assert r.status_code == 200
+
+        team = Teams.query.filter_by(id=2).first()
+        assert team
+        assert team.name == 'TunnelBunnies'
+        assert team.email == 'scary.hares@trace.us'
+        assert team.website == 'https://scary-hares.trace.us/'
+        assert team.affiliation == 'Energizer'
+        assert team.country == 'USA'
+    destroy_ctfd(app)
+
+
+def test_admin_create_team_without_required_fields():
+    '''Test that an admin can't create a new team without the required fields'''
+    app = create_ctfd()
+    with app.app_context():
+        client = login_as_user(app, name="admin", password="password")
+
+        with client.session_transaction() as sess:
+            data = {
+                'name': '',
+                'password': '',
+                'email': '',
+                'website': '',
+                'affiliation': '',
+                'country': '',
+                'nonce': sess.get('nonce'),
+            }
+            r = client.post('/admin/team/new', data=data)
+            assert r.status_code == 200
+
+        response = json.loads(r.get_data(as_text=True))
+        assert 'data' in response
+        assert len(response['data']) == 3
+        assert 'The team requires a name' in response['data']
+        assert 'The team requires an email' in response['data']
+        assert 'The team requires a password' in response['data']
+    destroy_ctfd(app)
+
+
+def test_admin_create_team_with_existing_name():
+    '''Test that an admin can't create a new team with an existing name'''
+    app = create_ctfd()
+    with app.app_context():
+        client = login_as_user(app, name="admin", password="password")
+
+        with client.session_transaction() as sess:
+            data = {
+                'name': 'admin',
+                'password': 'fUnn3lJuNK135',
+                'email': 'scary.hares@trace.us',
+                'website': 'https://scary-hares.trace.us/',
+                'affiliation': 'Energizer',
+                'country': 'USA',
+                'nonce': sess.get('nonce'),
+            }
+            r = client.post('/admin/team/new', data=data)
+            assert r.status_code == 200
+
+        response = json.loads(r.get_data(as_text=True))
+        assert 'data' in response
+        assert len(response['data']) == 1
+        assert 'That name is taken' in response['data']
+    destroy_ctfd(app)
+
+
+def test_admin_create_team_with_existing_email():
+    '''Test that an admin can't create a new team with an existing email'''
+    app = create_ctfd()
+    with app.app_context():
+        client = login_as_user(app, name="admin", password="password")
+
+        with client.session_transaction() as sess:
+            data = {
+                'name': 'TunnelBunnies',
+                'password': 'fUnn3lJuNK135',
+                'email': 'admin@ctfd.io',
+                'website': 'https://scary-hares.trace.us/',
+                'affiliation': 'Energizer',
+                'country': 'USA',
+                'nonce': sess.get('nonce'),
+            }
+            r = client.post('/admin/team/new', data=data)
+            assert r.status_code == 200
+
+        response = json.loads(r.get_data(as_text=True))
+        assert 'data' in response
+        assert len(response['data']) == 1
+        assert 'That email is taken' in response['data']
+    destroy_ctfd(app)
+
+
+def test_admin_create_team_with_invalid_website():
+    '''Test that an admin can't create a new team with an invalid website'''
+    app = create_ctfd()
+    with app.app_context():
+        client = login_as_user(app, name="admin", password="password")
+
+        with client.session_transaction() as sess:
+            data = {
+                'name': 'TunnelBunnies',
+                'password': 'fUnn3lJuNK135',
+                'email': 'scary.hares@trace.us',
+                'website': 'ftp://scary-hares.trace.us/',
+                'affiliation': 'Energizer',
+                'country': 'USA',
+                'nonce': sess.get('nonce'),
+            }
+            r = client.post('/admin/team/new', data=data)
+            assert r.status_code == 200
+
+        response = json.loads(r.get_data(as_text=True))
+        assert 'data' in response
+        assert len(response['data']) == 1
+        assert 'Websites must start with http:// or https://' in response['data']
+    destroy_ctfd(app)