Merge branch 'master' into production

This commit is contained in:
Nick Winter 2014-06-10 16:51:46 -07:00
commit 0527ca7229
19 changed files with 308 additions and 30 deletions

View file

@ -436,6 +436,7 @@
av_entities_sub_title: "Entities" av_entities_sub_title: "Entities"
av_entities_users_url: "Users" av_entities_users_url: "Users"
av_entities_active_instances_url: "Active Instances" av_entities_active_instances_url: "Active Instances"
av_entities_employer_list_url: "Employer List"
av_other_sub_title: "Other" av_other_sub_title: "Other"
av_other_debug_base_url: "Base (for debugging base.jade)" av_other_debug_base_url: "Base (for debugging base.jade)"
u_title: "User List" u_title: "User List"

View file

@ -113,8 +113,10 @@ UserSchema = c.object {},
signedEmployerAgreement: c.object {}, signedEmployerAgreement: c.object {},
linkedinID: c.shortString {title:"LinkedInID", description: "The user's LinkedIn ID when they signed the contract."} linkedinID: c.shortString {title:"LinkedInID", description: "The user's LinkedIn ID when they signed the contract."}
date: c.date {title: "Date signed employer agreement"} date: c.date {title: "Date signed employer agreement"}
data: c.object {description: "Cached LinkedIn data slurped from profile."} data: c.object {description: "Cached LinkedIn data slurped from profile.", additionalProperties: true}
points: {type:'number'} points: {type:'number'}
activity: {type: 'object', description: 'Summary statistics about user activity', additionalProperties: c.activity}
c.extendBasicProperties UserSchema, 'user' c.extendBasicProperties UserSchema, 'user'

View file

@ -170,3 +170,8 @@ me.FunctionArgumentSchema = me.object {
title: "Default" title: "Default"
description: "Default value of the argument. (Your code should set this.)" description: "Default value of the argument. (Your code should set this.)"
"default": null "default": null
me.activity = me.object {description: "Stats on an activity"},
first: me.date()
last: me.date()
count: {type: 'integer', minimum: 0}

View file

@ -44,11 +44,6 @@ block content
span(data-i18n='account_profile.not_featured') Not Featured span(data-i18n='account_profile.not_featured') Not Featured
if me.isAdmin() && !myProfile if me.isAdmin() && !myProfile
button.btn.edit-settings-button#enter-espionage-mode 007 button.btn.edit-settings-button#enter-espionage-mode 007
//if editing && myProfile
// a.sample-profile(href="http://codecombat.com/images/pages/account/profile/sample_profile.png", target="_blank")
// button.btn
// i.icon-user
// span(data-i18n="account_settings.sample_profile") See a sample profile
if profile && allowedToViewJobProfile if profile && allowedToViewJobProfile
div(class="job-profile-container" + (editing ? " editable-profile" : "")) div(class="job-profile-container" + (editing ? " editable-profile" : ""))

View file

@ -23,6 +23,8 @@ block content
a(href="/admin/users", data-i18n="admin.av_entities_users_url") Users a(href="/admin/users", data-i18n="admin.av_entities_users_url") Users
li li
a(href="/admin/level_sessions", data-i18n="admin.av_entities_active_instances_url") Active Instances a(href="/admin/level_sessions", data-i18n="admin.av_entities_active_instances_url") Active Instances
li
a(href="/admin/employer_list", data-i18n="admin.av_entities_employer_list_url") Employer List
h4(data-i18n="admin.av_other_sub_title") Other h4(data-i18n="admin.av_other_sub_title") Other

View file

@ -0,0 +1,65 @@
extends /templates/base
block content
if !me.isAdmin()
h1 Admin Only
else
h1(data-i18n="admin.av_entities_employer_list_url") Employer List
p
| We currently have
if employers.length
| #{employers.length}
else
| ...
| employers in the system.
if employers.length
table.table.table-condensed.table-hover.table-responsive.tablesorter
thead
tr
th(data-i18n="general.name") Name
th Company
th(data-i18n="general.email") Email
th Logins
th Candidates Viewed
th Candidates Contacted
th Signed Up
tbody
for employer, index in employers
- var activity = employer.get('activity') || {};
- var linkedIn = employer.get('signedEmployerAgreement').data
tr(data-employer-id=employer.id)
td
img(src=employer.getPhotoURL(50), height=50)
p
if employer.get('firstName')
span= employer.get('firstName') + ' ' + employer.get('lastName')
if employer.get('name')
| -
else if linkedIn.firstName
span= linkedIn.firstName + ' ' + linkedIn.lastName
if employer.get('name')
| -
if employer.get('name')
span= employer.get('name')
if !employer.get('firstName') && !linkedIn.firstName && !employer.get('name')
| Anoner
td
a(href=employer.get('signedEmployerAgreement').data.publicProfileUrl)= employer.get('employerAt')
td= employer.get('email')
for a in ['login', 'view_candidate', 'contact_candidate']
- var act = activity[a];
if act
td
strong= act.count
|
br
span= moment(activity.login.first).fromNow()
br
span= moment(activity.login.last).fromNow()
else
td 0
td(data-employer-age=(new Date() - new Date(employer.get('signedEmployerAgreement').date)) / 86400 / 1000)= moment(employer.get('signedEmployerAgreement').date).fromNow()

View file

@ -18,6 +18,16 @@ block content
| |
a(href="http://blog.codecombat.com/multiplayer-programming-tournament", data-i18n="ladder.tournament_blurb_blog") on our blog a(href="http://blog.codecombat.com/multiplayer-programming-tournament", data-i18n="ladder.tournament_blurb_blog") on our blog
| . | .
p
strong Tournament ended!
| At 5PM PDT, we took a snapshot of submitted code and are running an exhaustive pairwise ranking between all players. Winners will be sorted by
code wins - losses
| , and especially given deduplication on each team, final rankings may look significantly different than these preliminary rankings.
| We will announce winners as soon as we can. Thanks for playing!
p
| Want to commiserate? Head over to
a(href="http://discourse.codecombat.com/") the forum
| and discuss your strategies, your triumphs, and your turmoils.
.sponsor-logos .sponsor-logos
a(href="https://heapanalytics.com/") a(href="https://heapanalytics.com/")

View file

@ -59,6 +59,8 @@ module.exports = class ProfileView extends View
@user.fetch() @user.fetch()
@listenTo @user, "sync", => @listenTo @user, "sync", =>
@render() @render()
$.post "/db/user/#{me.id}/track/view_candidate"
$.post "/db/user/#{@userID}/track/viewed_by_employer" unless me.isAdmin()
else else
@user = User.getByID(@userID) @user = User.getByID(@userID)

View file

@ -0,0 +1,160 @@
View = require 'views/kinds/RootView'
template = require 'templates/admin/employer_list'
app = require 'application'
User = require 'models/User'
{me} = require 'lib/auth'
CocoCollection = require 'collections/CocoCollection'
ModelModal = require 'views/modal/model_modal'
class EmployersCollection extends CocoCollection
url: '/db/user/x/employers'
model: User
module.exports = class EmployersView extends View
id: "employers-view"
template: template
events:
'click tbody tr td:first-child': 'onEmployerClicked'
constructor: (options) ->
super options
@getEmployers()
afterRender: ->
super()
@sortTable() if @employers.models.length
getRenderData: ->
ctx = super()
ctx.employers = @employers.models
ctx.moment = moment
ctx
getEmployers: ->
@employers = new EmployersCollection()
@employers.fetch()
# Re-render when we have fetched them, but don't wait and show a progress bar while loading.
@listenToOnce @employers, 'all', => @render()
sortTable: ->
# http://mottie.github.io/tablesorter/docs/example-widget-bootstrap-theme.html
$.extend $.tablesorter.themes.bootstrap,
# these classes are added to the table. To see other table classes available,
# look here: http://twitter.github.com/bootstrap/base-css.html#tables
table: "table table-bordered"
caption: "caption"
header: "bootstrap-header" # give the header a gradient background
footerRow: ""
footerCells: ""
icons: "" # add "icon-white" to make them white; this icon class is added to the <i> in the header
sortNone: "bootstrap-icon-unsorted"
sortAsc: "icon-chevron-up" # glyphicon glyphicon-chevron-up" # we are still using v2 icons
sortDesc: "icon-chevron-down" # glyphicon-chevron-down" # we are still using v2 icons
active: "" # applied when column is sorted
hover: "" # use custom css here - bootstrap class may not override it
filterRow: "" # filter row class
even: "" # odd row zebra striping
odd: "" # even row zebra striping
# e = exact text from cell
# n = normalized value returned by the column parser
# f = search filter input value
# i = column index
# $r = ???
filterSelectExactMatch = (e, n, f, i, $r) -> e is f
# call the tablesorter plugin and apply the uitheme widget
@$el.find(".tablesorter").tablesorter
theme: "bootstrap"
widthFixed: true
headerTemplate: "{content} {icon}"
textSorter:
6: (a, b, direction, column, table) ->
days = []
for s in [a, b]
n = parseInt s
n = 0 unless _.isNumber n
n = 1 if /^a/.test s
for [duration, factor] in [
[/second/i, 1 / (86400 * 1000)]
[/minute/i, 1 / 1440]
[/hour/i, 1 / 24]
[/week/i, 7]
[/month/i, 30.42]
[/year/i, 365.2425]
]
if duration.test s
n *= factor
break
if /^in /i.test s
n *= -1
days.push n
days[0] - days[1]
sortList: [[6, 0]]
# widget code contained in the jquery.tablesorter.widgets.js file
# use the zebra stripe widget if you plan on hiding any rows (filter widget)
widgets: ["uitheme", "zebra", "filter"]
widgetOptions:
# using the default zebra striping class name, so it actually isn't included in the theme variable above
# this is ONLY needed for bootstrap theming if you are using the filter widget, because rows are hidden
zebra: ["even", "odd"]
# extra css class applied to the table row containing the filters & the inputs within that row
filter_cssFilter: ""
# If there are child rows in the table (rows with class name from "cssChildRow" option)
# and this option is true and a match is found anywhere in the child row, then it will make that row
# visible; default is false
filter_childRows: false
# if true, filters are collapsed initially, but can be revealed by hovering over the grey bar immediately
# below the header row. Additionally, tabbing through the document will open the filter row when an input gets focus
filter_hideFilters: false
# Set this option to false to make the searches case sensitive
filter_ignoreCase: true
# jQuery selector string of an element used to reset the filters
filter_reset: ".reset"
# Use the $.tablesorter.storage utility to save the most recent filters
filter_saveFilters: true
# Delay in milliseconds before the filter widget starts searching; This option prevents searching for
# every character while typing and should make searching large tables faster.
filter_searchDelay: 150
# Set this option to true to use the filter to find text from the start of the column
# So typing in "a" will find "albert" but not "frank", both have a's; default is false
filter_startsWith: false
filter_functions:
3:
"0-1": (e, n, f, i, $r) -> parseInt(e) <= 1
"2-5": (e, n, f, i, $r) -> 2 <= parseInt(e) <= 5
"6+": (e, n, f, i, $r) -> 6 <= parseInt(e)
4:
"0-1": (e, n, f, i, $r) -> parseInt(e) <= 1
"2-5": (e, n, f, i, $r) -> 2 <= parseInt(e) <= 5
"6+": (e, n, f, i, $r) -> 6 <= parseInt(e)
5:
"0-1": (e, n, f, i, $r) -> parseInt(e) <= 1
"2-5": (e, n, f, i, $r) -> 2 <= parseInt(e) <= 5
"6+": (e, n, f, i, $r) -> 6 <= parseInt(e)
6:
"Last day": (e, n, f, i, $r) ->
days = parseFloat $($r.find('td')[i]).data('employer-age')
days <= 1
"Last week": (e, n, f, i, $r) ->
days = parseFloat $($r.find('td')[i]).data('employer-age')
days <= 7
"Last 4 weeks": (e, n, f, i, $r) ->
days = parseFloat $($r.find('td')[i]).data('employer-age')
days <= 28
onEmployerClicked: (e) ->
return unless id = $(e.target).closest('tr').data('employer-id')
employer = new User _id: id
@openModalView new ModelModal models: [employer]

View file

@ -181,9 +181,6 @@ module.exports = class EmployersView extends View
7: 7:
"": filterSelectExactMatch "": filterSelectExactMatch
"": filterSelectExactMatch "": filterSelectExactMatch
8:
"": filterSelectExactMatch
"": filterSelectExactMatch
onCandidateClicked: (e) -> onCandidateClicked: (e) ->
id = $(e.target).closest('tr').data('candidate-id') id = $(e.target).closest('tr').data('candidate-id')

View file

@ -33,3 +33,4 @@ module.exports = class ContactView extends View
return forms.applyErrorsToForm @$el, res.errors unless res.valid return forms.applyErrorsToForm @$el, res.errors unless res.valid
window.tracker?.trackEvent 'Sent Feedback', message: contactMessage window.tracker?.trackEvent 'Sent Feedback', message: contactMessage
sendContactMessage contactMessage, @$el sendContactMessage contactMessage, @$el
$.post "/db/user/#{me.id}/track/contact_codecombat"

View file

@ -39,3 +39,5 @@ module.exports = class JobProfileContactView extends ContactView
contactMessage.message += '\n\n\n\n[CodeCombat says: please let us know if you end up accepting this job. Thanks!]' contactMessage.message += '\n\n\n\n[CodeCombat says: please let us know if you end up accepting this job. Thanks!]'
window.tracker?.trackEvent 'Sent Job Profile Message', message: contactMessage window.tracker?.trackEvent 'Sent Job Profile Message', message: contactMessage
sendContactMessage contactMessage, @$el sendContactMessage contactMessage, @$el
$.post "/db/user/#{me.id}/track/contact_candidate"
$.post "/db/user/#{@options.recipientID}/track/contacted_by_employer" unless me.isAdmin()

View file

@ -3,14 +3,10 @@ startsWith = (string, substring) ->
string.lastIndexOf(substring, 0) is 0 string.lastIndexOf(substring, 0) is 0
exports.config = exports.config =
server:
path: 'server.coffee'
paths: paths:
'public': 'public' 'public': 'public'
conventions: conventions:
ignored: (path) -> startsWith(sysPath.basename(path), '_') ignored: (path) -> startsWith(sysPath.basename(path), '_')
workers:
enabled: false # turned out to be much, much slower than without workers
sourceMaps: true sourceMaps: true
files: files:
javascripts: javascripts:
@ -55,7 +51,6 @@ exports.config =
'vendor/scripts/movieclip-NEXT.min.js' 'vendor/scripts/movieclip-NEXT.min.js'
# Validated Backbone Mediator dependencies # Validated Backbone Mediator dependencies
'bower_components/tv4/tv4.js' 'bower_components/tv4/tv4.js'
# Aether before box2d for some strange Object.defineProperty thing # Aether before box2d for some strange Object.defineProperty thing
'bower_components/aether/build/aether.js' 'bower_components/aether/build/aether.js'
'bower_components/d3/d3.min.js' 'bower_components/d3/d3.min.js'
@ -77,7 +72,7 @@ exports.config =
plugins: plugins:
autoReload: autoReload:
delay: 300 # for race conditions, particularly waiting for onCompile to do its thing delay: 300
coffeelint: coffeelint:
pattern: /^app\/.*\.coffee$/ pattern: /^app\/.*\.coffee$/
options: options:

View file

@ -12,7 +12,6 @@ LevelComponentSchema.plugin plugins.PermissionsPlugin
LevelComponentSchema.plugin plugins.VersionedPlugin LevelComponentSchema.plugin plugins.VersionedPlugin
LevelComponentSchema.plugin plugins.SearchablePlugin, {searchable: ['name', 'description', 'system']} LevelComponentSchema.plugin plugins.SearchablePlugin, {searchable: ['name', 'description', 'system']}
LevelComponentSchema.plugin plugins.PatchablePlugin LevelComponentSchema.plugin plugins.PatchablePlugin
LevelComponentSchema.plugin plugins.MigrationPlugin, {'language': 'codeLanguage'}
LevelComponentSchema.pre 'init', (next) -> LevelComponentSchema.pre 'init', (next) ->
return next() unless jsonschema.properties? return next() unless jsonschema.properties?

View file

@ -11,7 +11,6 @@ LevelSystemSchema.plugin(plugins.PermissionsPlugin)
LevelSystemSchema.plugin(plugins.VersionedPlugin) LevelSystemSchema.plugin(plugins.VersionedPlugin)
LevelSystemSchema.plugin(plugins.SearchablePlugin, {searchable: ['name', 'description']}) LevelSystemSchema.plugin(plugins.SearchablePlugin, {searchable: ['name', 'description']})
LevelSystemSchema.plugin(plugins.PatchablePlugin) LevelSystemSchema.plugin(plugins.PatchablePlugin)
LevelSystemSchema.plugin plugins.MigrationPlugin, {'language': 'codeLanguage'}
LevelSystemSchema.pre 'init', (next) -> LevelSystemSchema.pre 'init', (next) ->
return next() unless jsonschema.properties? return next() unless jsonschema.properties?

View file

@ -9,7 +9,7 @@ module.exports.MigrationPlugin = (schema, migrations) ->
# 1. Change the schema and the client/server logic to use the new name # 1. Change the schema and the client/server logic to use the new name
# 2. Add this plugin to the target models, passing in a dictionary of old/new names. # 2. Add this plugin to the target models, passing in a dictionary of old/new names.
# 3. Check that tests still run, deploy to production. # 3. Check that tests still run, deploy to production.
# 4. Run db.<collection>.update({}, { $rename: {'<oldname>':'<newname>'} }) on the server # 4. Run db.<collection>.update({}, { $rename: {'<oldname>':'<newname>'} }, { multi: true }) on the server
# 5. Remove the names you added to the migrations dictionaries for the next deploy # 5. Remove the names you added to the migrations dictionaries for the next deploy
schema.post 'init', -> schema.post 'init', ->

View file

@ -60,6 +60,10 @@ module.exports.setup = (app) ->
return errors.unauthorized(res, [{message:info.message, property:info.property}]) return errors.unauthorized(res, [{message:info.message, property:info.property}])
req.logIn(user, (err) -> req.logIn(user, (err) ->
return next(err) if (err)
activity = req.user.trackActivity 'login', 1
console.log "updating", activity
user.update {activity: activity}, (err) ->
return next(err) if (err) return next(err) if (err)
res.send(UserHandler.formatEntity(req, req.user)) res.send(UserHandler.formatEntity(req, req.user))
return res.end() return res.end()
@ -181,4 +185,3 @@ createMailOptions = (receiver, password) ->
replyTo: config.mail.username replyTo: config.mail.username
subject: "[CodeCombat] Password Reset" subject: "[CodeCombat] Password Reset"
text: "You can log into your account with: #{password}" text: "You can log into your account with: #{password}"

View file

@ -29,6 +29,17 @@ UserSchema.methods.isAdmin = ->
p = @get('permissions') p = @get('permissions')
return p and 'admin' in p return p and 'admin' in p
UserSchema.methods.trackActivity = (activityName, increment) ->
now = new Date()
increment ?= parseInt increment or 1
increment = Math.max increment, 0
activity = @get('activity') ? {}
activity[activityName] ?= {first: now, count: 0}
activity[activityName].count += increment
activity[activityName].last = now
@set 'activity', activity
activity
emailNameMap = emailNameMap =
generalNews: 'announcement' generalNews: 'announcement'
adventurerNews: 'tester' adventurerNews: 'tester'

View file

@ -48,7 +48,7 @@ UserHandler = class UserHandler extends Handler
delete obj[prop] for prop in serverProperties delete obj[prop] for prop in serverProperties
includePrivates = req.user and (req.user.isAdmin() or req.user._id.equals(document._id)) includePrivates = req.user and (req.user.isAdmin() or req.user._id.equals(document._id))
delete obj[prop] for prop in privateProperties unless includePrivates delete obj[prop] for prop in privateProperties unless includePrivates
includeCandidate = includePrivates or (obj.jobProfileApproved and req.user and ('employer' in (req.user.get('permissions') ? [])) and @employerCanViewCandidate req.user, obj) includeCandidate = includePrivates or (obj.jobProfile?.active and req.user and ('employer' in (req.user.get('permissions') ? [])) and @employerCanViewCandidate req.user, obj)
delete obj[prop] for prop in candidateProperties unless includeCandidate delete obj[prop] for prop in candidateProperties unless includeCandidate
return obj return obj
@ -191,9 +191,11 @@ UserHandler = class UserHandler extends Handler
return @nameToID(req, res, args[0]) if args[1] is 'nameToID' return @nameToID(req, res, args[0]) if args[1] is 'nameToID'
return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions' return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions'
return @getCandidates(req, res) if args[1] is 'candidates' return @getCandidates(req, res) if args[1] is 'candidates'
return @getEmployers(req, res) if args[1] is 'employers'
return @getSimulatorLeaderboard(req, res, args[0]) if args[1] is 'simulatorLeaderboard' return @getSimulatorLeaderboard(req, res, args[0]) if args[1] is 'simulatorLeaderboard'
return @getMySimulatorLeaderboardRank(req, res, args[0]) if args[1] is 'simulator_leaderboard_rank' return @getMySimulatorLeaderboardRank(req, res, args[0]) if args[1] is 'simulator_leaderboard_rank'
return @getEarnedAchievements(req, res, args[0]) if args[1] is 'achievements' return @getEarnedAchievements(req, res, args[0]) if args[1] is 'achievements'
return @trackActivity(req, res, args[0], args[2], args[3]) if args[1] is 'track' and args[2]
return @sendNotFoundError(res) return @sendNotFoundError(res)
super(arguments...) super(arguments...)
@ -249,6 +251,25 @@ UserHandler = class UserHandler extends Handler
doc.save() doc.save()
@sendSuccess(res, cleandocs) @sendSuccess(res, cleandocs)
trackActivity: (req, res, userID, activityName, increment=1) ->
return @sendMethodNotAllowed res unless req.method is 'POST'
isMe = userID is req.user._id + ''
isAuthorized = isMe or req.user.isAdmin()
isAuthorized ||= ('employer' in req.user.get('permissions')) and (activityName in ['viewed_by_employer', 'messaged_by_employer'])
return @sendUnauthorizedError res unless isAuthorized
updateUser = (user) =>
activity = user.trackActivity activityName, increment
user.update {activity: activity}, (err) =>
return @sendDatabaseError res, err if err
@sendSuccess res, result: 'success'
if isMe
updateUser(req.user)
else
@getDocumentForIdOrSlug userID, (err, user) =>
return @sendDatabaseError res, err if err
return @sendNotFoundError res unless user
updateUser user
agreeToEmployerAgreement: (req, res) -> agreeToEmployerAgreement: (req, res) ->
userIsAnonymous = req.user?.get('anonymous') userIsAnonymous = req.user?.get('anonymous')
if userIsAnonymous then return errors.unauthorized(res, "You need to be logged in to agree to the employer agreeement.") if userIsAnonymous then return errors.unauthorized(res, "You need to be logged in to agree to the employer agreeement.")
@ -281,7 +302,7 @@ UserHandler = class UserHandler extends Handler
query = {'jobProfile.updated': {$gt: since}} query = {'jobProfile.updated': {$gt: since}}
#query.jobProfileApproved = true unless req.user.isAdmin() # We split into featured and other now. #query.jobProfileApproved = true unless req.user.isAdmin() # We split into featured and other now.
query['jobProfile.active'] = true unless req.user.isAdmin() query['jobProfile.active'] = true unless req.user.isAdmin()
selection = 'jobProfile jobProfileApproved' selection = 'jobProfile jobProfileApproved photoURL'
selection += ' email' if authorized selection += ' email' if authorized
User.find(query).select(selection).exec (err, documents) => User.find(query).select(selection).exec (err, documents) =>
return @sendDatabaseError(res, err) if err return @sendDatabaseError(res, err) if err
@ -309,6 +330,14 @@ UserHandler = class UserHandler extends Handler
return false if job.employer?.toLowerCase() is employer.get('employerAt')?.toLowerCase() return false if job.employer?.toLowerCase() is employer.get('employerAt')?.toLowerCase()
true true
getEmployers: (req, res) ->
return @sendUnauthorizedError(res) unless req.user.isAdmin()
query = {employerAt: {$exists: true}}
selection = 'name firstName lastName email activity signedEmployerAgreement photoURL employerAt'
User.find(query).select(selection).lean().exec (err, documents) =>
return @sendDatabaseError res, err if err
@sendSuccess res, documents
buildGravatarURL: (user, size, fallback) -> buildGravatarURL: (user, size, fallback) ->
emailHash = @buildEmailHash user emailHash = @buildEmailHash user
fallback ?= "http://codecombat.com/file/db/thang.type/52a00d55cf1818f2be00000b/portrait.png" fallback ?= "http://codecombat.com/file/db/thang.type/52a00d55cf1818f2be00000b/portrait.png"