diff --git a/app/controllers/team.js b/app/controllers/team.js new file mode 100644 index 00000000000..06705f6e39e --- /dev/null +++ b/app/controllers/team.js @@ -0,0 +1,17 @@ +import Ember from 'ember'; +import PaginationMixin from '../mixins/pagination'; + +const { computed } = Ember; + +export default Ember.Controller.extend(PaginationMixin, { + queryParams: ['page', 'per_page', 'sort'], + page: '1', + per_page: 10, + sort: 'alpha', + + totalItems: computed.readOnly('model.crates.meta.total'), + + currentSortBy: computed('sort', function() { + return (this.get('sort') === 'downloads') ? 'Downloads' : 'Alphabetical'; + }), +}); diff --git a/app/models/crate.js b/app/models/crate.js index 82457470419..3c7fda4e962 100644 --- a/app/models/crate.js +++ b/app/models/crate.js @@ -30,6 +30,8 @@ export default DS.Model.extend({ badge_sort: ['badge_type'], annotated_badges: Ember.computed.sort('enhanced_badges', 'badge_sort'), owners: DS.hasMany('users', { async: true }), + owner_team: DS.hasMany('teams', { async: true }), + owner_user: DS.hasMany('users', { async: true }), version_downloads: DS.hasMany('version-download', { async: true }), keywords: DS.hasMany('keywords', { async: true }), categories: DS.hasMany('categories', { async: true }), diff --git a/app/models/team.js b/app/models/team.js new file mode 100644 index 00000000000..b1aef428566 --- /dev/null +++ b/app/models/team.js @@ -0,0 +1,17 @@ +import DS from 'ember-data'; +import Ember from 'ember'; + +export default DS.Model.extend({ + email: DS.attr('string'), + name: DS.attr('string'), + login: DS.attr('string'), + api_token: DS.attr('string'), + avatar: DS.attr('string'), + url: DS.attr('string'), + kind: DS.attr('string'), + org_name: Ember.computed('login', function() { + let login = this.get('login'); + let login_split = login.split(':'); + return login_split[1]; + }) +}); diff --git a/app/models/user.js b/app/models/user.js index d1a736e1a76..3ea87236bc7 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -7,4 +7,5 @@ export default DS.Model.extend({ api_token: DS.attr('string'), avatar: DS.attr('string'), url: DS.attr('string'), + kind: DS.attr('string'), }); diff --git a/app/router.js b/app/router.js index fc7ce9fa751..189b04cf417 100644 --- a/app/router.js +++ b/app/router.js @@ -44,6 +44,7 @@ Router.map(function() { }); this.route('category_slugs'); this.route('catchAll', { path: '*path' }); + this.route('team', { path: '/teams/:team_id' }); }); export default Router; diff --git a/app/routes/team.js b/app/routes/team.js new file mode 100644 index 00000000000..631e57751e4 --- /dev/null +++ b/app/routes/team.js @@ -0,0 +1,37 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + queryParams: { + page: { refreshedModel: true }, + sort: { refreshedModel: true }, + }, + data: {}, + + setupController(controller, model) { + this._super(controller, model); + + controller.set('fetchingFeed', true); + controller.set('crates', this.get('data.crates')); + }, + + model(params) { + const { team_id } = params; + return this.store.find('team', team_id).then( + (team) => { + params.team_id = team.get('id'); + return Ember.RSVP.hash({ + crates: this.store.query('crate', params), + team + }); + }, + (e) => { + if (e.errors.any(e => e.detail === 'Not Found')) { + this + .controllerFor('application') + .set('nextFlashError', `User '${params.team_id}' does not exist`); + return this.replaceWith('index'); + } + } + ); + }, +}); diff --git a/app/styles/crate.scss b/app/styles/crate.scss index 3645a388037..896329dff8f 100644 --- a/app/styles/crate.scss +++ b/app/styles/crate.scss @@ -22,6 +22,9 @@ img { @include align-self(center); } + .team-info { + @include flex-direction(column); + } } h1 { padding-left: 10px; diff --git a/app/templates/crate/version.hbs b/app/templates/crate/version.hbs index df660b65f02..f6fd050c8d4 100644 --- a/app/templates/crate/version.hbs +++ b/app/templates/crate/version.hbs @@ -148,11 +148,19 @@

Owners

diff --git a/app/templates/team.hbs b/app/templates/team.hbs new file mode 100644 index 00000000000..76aba4c7f7f --- /dev/null +++ b/app/templates/team.hbs @@ -0,0 +1,76 @@ +
+
+
+ {{user-avatar user=model.team size='medium'}} +
+

+ {{ model.team.org_name }} +

+

+ {{ model.team.name }} +

+
+ {{#user-link user=model.team}} + GitHub profile + {{/user-link}} +
+
+
+ +
+
+ {{! TODO: reduce duplication with templates/crates.hbs }} + +
+ + +
+ Sort by + {{#rl-dropdown-container class="dropdown-container"}} + {{#rl-dropdown-toggle tagName="a" class="dropdown"}} + + {{ currentSortBy }} + + {{/rl-dropdown-toggle}} + + {{#rl-dropdown tagName="ul" class="dropdown" closeOnChildClick="a:link"}} +
  • + {{#link-to (query-params sort="alpha")}} + Alphabetical + {{/link-to}} +
  • +
  • + {{#link-to (query-params sort="downloads")}} + Downloads + {{/link-to}} +
  • + {{/rl-dropdown}} + {{/rl-dropdown-container}} +
    +
    + +
    + {{#each model.crates as |crate|}} + {{crate-row crate=crate}} + {{/each}} +
    + + +
    +
    diff --git a/mirage/config.js b/mirage/config.js index befc6e001a3..40e17bf9f60 100644 --- a/mirage/config.js +++ b/mirage/config.js @@ -5,10 +5,13 @@ import crateFixture from '../mirage/fixtures/crate'; import crateVersionsFixture from '../mirage/fixtures/crate_versions'; import crateAuthorsFixture from '../mirage/fixtures/crate_authors'; import crateOwnersFixture from '../mirage/fixtures/crate_owners'; +import crateTeamsFixture from '../mirage/fixtures/crate_teams'; import crateReverseDependenciesFixture from '../mirage/fixtures/crate_reverse_dependencies'; import crateDependenciesFixture from '../mirage/fixtures/crate_dependencies'; import crateDownloadsFixture from '../mirage/fixtures/crate_downloads'; import keywordFixture from '../mirage/fixtures/keyword'; +import teamFixture from '../mirage/fixtures/team'; +import userFixture from '../mirage/fixtures/user'; export default function() { this.get('/summary', () => summaryFixture); @@ -20,6 +23,20 @@ export default function() { crates: searchFixture.crates.slice(start, end), meta: searchFixture.meta, }; + } else if (request.queryParams.team_id) { + const { start, end } = pageParams(request); + return { + team: teamFixture.team, + crates: searchFixture.crates.slice(start, end), + meta: searchFixture.meta, + }; + } else if (request.queryParams.user_id) { + const { start, end } = pageParams(request); + return { + user: userFixture.user, + crates: searchFixture.crates.slice(start, end), + meta: searchFixture.meta, + }; } }); @@ -28,11 +45,14 @@ export default function() { this.get('/api/v1/crates/nanomsg', () => crateFixture); this.get('/api/v1/crates/nanomsg/versions', () => crateVersionsFixture); this.get('/api/v1/crates/nanomsg/:version_num/authors', () => crateAuthorsFixture); - this.get('/api/v1/crates/nanomsg/owners', () => crateOwnersFixture); + this.get('/api/v1/crates/nanomsg/owner_user', () => crateOwnersFixture); + this.get('/api/v1/crates/nanomsg/owner_team', () => crateTeamsFixture); this.get('/api/v1/crates/nanomsg/reverse_dependencies', () => crateReverseDependenciesFixture); this.get('/api/v1/crates/nanomsg/:version_num/dependencies', () => crateDependenciesFixture); this.get('/api/v1/crates/nanomsg/downloads', () => crateDownloadsFixture); this.get('/api/v1/keywords/network', () => keywordFixture); + this.get('/api/v1/teams/:team_id', () => teamFixture); + this.get('/api/v1/users/:user_id', () => userFixture); } function pageParams(request) { diff --git a/mirage/fixtures/crate.js b/mirage/fixtures/crate.js index 51ea63abf62..330ebd45dbf 100644 --- a/mirage/fixtures/crate.js +++ b/mirage/fixtures/crate.js @@ -15,7 +15,8 @@ export default { ], "license": "MIT", "links": { - "owners": "/api/v1/crates/nanomsg/owners", + "owner_user": "/api/v1/crates/nanomsg/owner_user", + "owner_team": "/api/v1/crates/nanomsg/owner_team", "reverse_dependencies": "/api/v1/crates/nanomsg/reverse_dependencies", "version_downloads": "/api/v1/crates/nanomsg/downloads", "versions": null diff --git a/mirage/fixtures/crate_teams.js b/mirage/fixtures/crate_teams.js new file mode 100644 index 00000000000..a82255249ec --- /dev/null +++ b/mirage/fixtures/crate_teams.js @@ -0,0 +1,23 @@ +// jscs:disable validateQuoteMarks +export default { + "teams": [ + { + "avatar": "https://avatars.githubusercontent.com/u/565790?v=3", + "email": "someone@example.com", + "id": 2, + "kind": "team", + "login": "github:org:thehydroimpulse", + "name": "Team Daniel Fagnan", + "url": "https://github.com/thehydroimpulse" + }, + { + "avatar": "https://avatars.githubusercontent.com/u/9447137?v=3", + "email": null, + "id": 303, + "kind": "team", + "login": "github:org:blabaere", + "name": "Team BenoƮt Labaere", + "url": "https://github.com/blabaere" + } + ] +}; diff --git a/mirage/fixtures/team.js b/mirage/fixtures/team.js new file mode 100644 index 00000000000..c7c77aa0adc --- /dev/null +++ b/mirage/fixtures/team.js @@ -0,0 +1,10 @@ +// jscs:disable validateQuoteMarks +export default { + "team": { + "avatar": "https://avatars.githubusercontent.com/u/565790?v=3", + "id": 1, + "login": "github:org_test:thehydroimpulseteam", + "name": "thehydroimpulseteam", + "url": "https://github.com/thehydroimpulse", + } +}; diff --git a/mirage/fixtures/user.js b/mirage/fixtures/user.js new file mode 100644 index 00000000000..f26e1175cf6 --- /dev/null +++ b/mirage/fixtures/user.js @@ -0,0 +1,11 @@ +// jscs:disable validateQuoteMarks +export default { + "user": { + "avatar": "https://avatars.githubusercontent.com/u/565790?v=3", + "email": "someone@example.com", + "id": 1, + "login": "thehydroimpulse", + "name": "Daniel Fagnan", + "url": "https://github.com/thehydroimpulse" + } +}; diff --git a/src/krate.rs b/src/krate.rs index 8c09683bc94..84e0db5ccd4 100644 --- a/src/krate.rs +++ b/src/krate.rs @@ -114,6 +114,8 @@ pub struct CrateLinks { pub version_downloads: String, pub versions: Option, pub owners: Option, + pub owner_team: Option, + pub owner_user: Option, pub reverse_dependencies: String, } @@ -535,6 +537,8 @@ impl Crate { version_downloads: format!("/api/v1/crates/{}/downloads", name), versions: versions_link, owners: Some(format!("/api/v1/crates/{}/owners", name)), + owner_team: Some(format!("/api/v1/crates/{}/owner_team", name)), + owner_user: Some(format!("/api/v1/crates/{}/owner_user", name)), reverse_dependencies: format!("/api/v1/crates/{}/reverse_dependencies", name), }, } @@ -864,6 +868,15 @@ pub fn index(req: &mut Request) -> CargoResult { .filter(crate_owners::owner_kind.eq(OwnerKind::User as i32)), ), ); + } else if let Some(team_id) = params.get("team_id").and_then(|s| s.parse::().ok()) { + query = query.filter( + crates::id.eq_any( + crate_owners::table + .select(crate_owners::crate_id) + .filter(crate_owners::owner_id.eq(team_id)) + .filter(crate_owners::owner_kind.eq(OwnerKind::Team as i32)), + ), + ); } else if params.get("following").is_some() { query = query.filter(crates::id.eq_any( follows::table.select(follows::crate_id).filter( @@ -1423,6 +1436,40 @@ pub fn owners(req: &mut Request) -> CargoResult { Ok(req.json(&R { users: owners })) } +/// Handles the `GET /crates/:crate_id/owner_team` route. +pub fn owner_team(req: &mut Request) -> CargoResult { + let crate_name = &req.params()["crate_id"]; + let conn = req.db_conn()?; + let krate = Crate::by_name(crate_name).first::(&*conn)?; + let owners = Team::owning(&krate, &conn)? + .into_iter() + .map(Owner::encodable) + .collect(); + + #[derive(RustcEncodable)] + struct R { + teams: Vec, + } + Ok(req.json(&R { teams: owners })) +} + +/// Handles the `GET /crates/:crate_id/owner_user` route. +pub fn owner_user(req: &mut Request) -> CargoResult { + let crate_name = &req.params()["crate_id"]; + let conn = req.db_conn()?; + let krate = Crate::by_name(crate_name).first::(&*conn)?; + let owners = User::owning(&krate, &conn)? + .into_iter() + .map(Owner::encodable) + .collect(); + + #[derive(RustcEncodable)] + struct R { + users: Vec, + } + Ok(req.json(&R { users: owners })) +} + /// Handles the `PUT /crates/:crate_id/owners` route. pub fn add_owners(req: &mut Request) -> CargoResult { modify_owners(req, true) diff --git a/src/lib.rs b/src/lib.rs index db1d485e6c7..55388b830cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -129,6 +129,8 @@ pub fn middleware(app: Arc) -> MiddlewareBuilder { api_router.delete("/crates/:crate_id/follow", C(krate::unfollow)); api_router.get("/crates/:crate_id/following", C(krate::following)); api_router.get("/crates/:crate_id/owners", C(krate::owners)); + api_router.get("/crates/:crate_id/owner_team", C(krate::owner_team)); + api_router.get("/crates/:crate_id/owner_user", C(krate::owner_user)); api_router.put("/crates/:crate_id/owners", C(krate::add_owners)); api_router.delete("/crates/:crate_id/owners", C(krate::remove_owners)); api_router.delete("/crates/:crate_id/:version/yank", C(version::yank)); @@ -145,6 +147,7 @@ pub fn middleware(app: Arc) -> MiddlewareBuilder { api_router.get("/categories/:category_id", C(category::show)); api_router.get("/category_slugs", C(category::slugs)); api_router.get("/users/:user_id", C(user::show)); + api_router.get("/teams/:team_id", C(user::show_team)); let api_router = Arc::new(R404(api_router)); let mut router = RouteBuilder::new(); diff --git a/src/owner.rs b/src/owner.rs index 407527055c2..9b91e9b6af7 100644 --- a/src/owner.rs +++ b/src/owner.rs @@ -1,4 +1,3 @@ -use diesel; use diesel::prelude::*; use diesel::pg::PgConnection; use pg::rows::Row; @@ -36,7 +35,7 @@ pub enum Owner { /// For now, just a Github Team. Can be upgraded to other teams /// later if desirable. -#[derive(Queryable, Identifiable)] +#[derive(Queryable, Identifiable, RustcEncodable, RustcDecodable)] pub struct Team { /// Unique table id pub id: i32, @@ -53,6 +52,15 @@ pub struct Team { } #[derive(RustcEncodable)] +pub struct EncodableTeam { + pub id: i32, + pub login: String, + pub name: Option, + pub avatar: Option, + pub url: Option, +} + +#[derive(RustcEncodable, RustcDecodable)] pub struct EncodableOwner { pub id: i32, pub login: String, @@ -72,6 +80,41 @@ pub enum Rights { Full, } +#[derive(Insertable, AsChangeset)] +#[table_name = "teams"] +pub struct NewTeam<'a> { + pub login: &'a str, + pub github_id: i32, + pub name: Option, + pub avatar: Option, +} + +impl<'a> NewTeam<'a> { + pub fn new( + login: &'a str, + github_id: i32, + name: Option, + avatar: Option, + ) -> Self { + NewTeam { + login: login, + github_id: github_id, + name: name, + avatar: avatar, + } + } + + pub fn create_or_update(&self, conn: &PgConnection) -> CargoResult { + use diesel::insert; + use diesel::pg::upsert::*; + + insert(&self.on_conflict(teams::github_id, do_update().set(self))) + .into(teams::table) + .get_result(conn) + .map_err(Into::into) + } +} + impl Team { /// Tries to create the Team in the DB (assumes a `:` has already been found). pub fn create( @@ -172,39 +215,7 @@ impl Team { let (handle, resp) = http::github(app, &url, &token)?; let org: Org = http::parse_github_response(handle, &resp)?; - Team::insert(conn, login, team.id, team.name, org.avatar_url) - } - - pub fn insert( - conn: &PgConnection, - login: &str, - github_id: i32, - name: Option, - avatar: Option, - ) -> CargoResult { - use diesel::pg::upsert::*; - - #[derive(Insertable, AsChangeset)] - #[table_name = "teams"] - struct NewTeam<'a> { - login: &'a str, - github_id: i32, - name: Option, - avatar: Option, - } - let new_team = NewTeam { - login: login, - github_id: github_id, - name: name, - avatar: avatar, - }; - - diesel::insert(&new_team.on_conflict( - teams::github_id, - do_update().set(&new_team), - )).into(teams::table) - .get_result(conn) - .map_err(Into::into) + NewTeam::new(login, team.id, team.name, org.avatar_url).create_or_update(conn) } /// Phones home to Github to ask if this User is a member of the given team. @@ -214,6 +225,49 @@ impl Team { pub fn contains_user(&self, app: &App, user: &User) -> CargoResult { team_with_gh_id_contains_user(app, self.github_id, user) } + + pub fn owning(krate: &Crate, conn: &PgConnection) -> CargoResult> { + let base_query = CrateOwner::belonging_to(krate).filter(crate_owners::deleted.eq(false)); + let teams = base_query + .inner_join(teams::table) + .select(teams::all_columns) + .filter(crate_owners::owner_kind.eq(OwnerKind::Team as i32)) + .load(conn)? + .into_iter() + .map(Owner::Team); + + Ok(teams.collect()) + } + + pub fn encodable(self) -> EncodableTeam { + let Team { + id, + name, + login, + avatar, + .. + } = self; + let url = Team::github_url(&login); + + EncodableTeam { + id: id, + login: login, + name: name, + avatar: avatar, + url: Some(url), + } + } + + fn github_url(login: &str) -> String { + let mut login_pieces = login.split(':'); + login_pieces.next(); + + format!( + "https://github.com/orgs/{}/teams/{}", + login_pieces.next().expect("org failed"), + login_pieces.next().expect("team failed") + ) + } } fn team_with_gh_id_contains_user(app: &App, github_id: i32, user: &User) -> CargoResult { @@ -330,15 +384,7 @@ impl Owner { avatar, .. }) => { - let url = { - let mut parts = login.split(':'); - parts.next(); // discard github - format!( - "https://github.com/orgs/{}/teams/{}", - parts.next().unwrap(), - parts.next().unwrap() - ) - }; + let url = Team::github_url(&login); EncodableOwner { id: id, login: login, diff --git a/src/tests/all.rs b/src/tests/all.rs index 61b4255a5e7..043f3ea43f9 100644 --- a/src/tests/all.rs +++ b/src/tests/all.rs @@ -31,12 +31,15 @@ use cargo_registry::keyword::Keyword; use cargo_registry::krate::NewCrate; use cargo_registry::upload as u; use cargo_registry::user::NewUser; +use cargo_registry::owner::{CrateOwner, NewTeam, Team}; use cargo_registry::version::NewVersion; use cargo_registry::{User, Crate, Version, Dependency, Category, Model, Replica}; use conduit::{Request, Method}; use conduit_test::MockRequest; use diesel::pg::PgConnection; use diesel::prelude::*; +use diesel::pg::upsert::*; +use cargo_registry::schema::*; macro_rules! t { ($e:expr) => ( @@ -212,6 +215,32 @@ fn user(login: &str) -> User { } } +fn new_team(login: &str) -> NewTeam { + NewTeam { + github_id: NEXT_ID.fetch_add(1, Ordering::SeqCst) as i32, + login: login, + name: None, + avatar: None, + } +} + +fn add_team_to_crate(t: &Team, krate: &Crate, u: &User, conn: &PgConnection) -> CargoResult<()> { + let crate_owner = CrateOwner { + crate_id: krate.id, + owner_id: t.id, + created_by: u.id, + owner_kind: 1, // Team owner kind is 1 according to owner.rs + }; + + diesel::insert(&crate_owner.on_conflict( + crate_owners::table.primary_key(), + do_update().set(crate_owners::deleted.eq(false)), + )).into(crate_owners::table) + .execute(conn)?; + + Ok(()) +} + use cargo_registry::util::CargoResult; struct CrateBuilder<'a> { diff --git a/src/tests/krate.rs b/src/tests/krate.rs index 6875d7b478b..1c6df816eff 100644 --- a/src/tests/krate.rs +++ b/src/tests/krate.rs @@ -18,6 +18,7 @@ use cargo_registry::upload as u; use cargo_registry::user::EncodableUser; use cargo_registry::version::EncodableVersion; use cargo_registry::category::Category; +use cargo_registry::owner::EncodableOwner; #[derive(RustcDecodable)] struct CrateList { @@ -61,6 +62,14 @@ struct RevDeps { struct Downloads { version_downloads: Vec, } +#[derive(RustcDecodable)] +struct TeamResponse { + teams: Vec, +} +#[derive(RustcDecodable)] +struct UserResponse { + users: Vec, +} fn new_crate(name: &str) -> u::NewCrate { u::NewCrate { @@ -1740,3 +1749,92 @@ fn author_license_and_description_required() { json.errors ); } + +/* Testing the crate ownership between two crates and one team. + Given two crates, one crate owned by both a team and a user, + one only owned by a user, check that the CrateList returned + for the user_id contains only the crates owned by that user, + and that the CrateList returned for the team_id contains + only crates owned by that team. +*/ +#[test] +fn check_ownership_two_crates() { + let (_b, app, middle) = ::app(); + + let (krate_owned_by_team, team) = { + let conn = app.diesel_database.get().unwrap(); + let u = ::new_user("user_foo").create_or_update(&conn).unwrap(); + let t = ::new_team("team_foo").create_or_update(&conn).unwrap(); + let krate = ::CrateBuilder::new("foo", u.id).expect_build(&conn); + ::add_team_to_crate(&t, &krate, &u, &conn).unwrap(); + (krate, t) + }; + + let (krate_not_owned_by_team, user) = { + let conn = app.diesel_database.get().unwrap(); + let u = ::new_user("user_bar").create_or_update(&conn).unwrap(); + (::CrateBuilder::new("bar", u.id).expect_build(&conn), u) + }; + + let mut req = ::req(app.clone(), Method::Get, "/api/v1/crates"); + + let query = format!("user_id={}", user.id); + let mut response = ok_resp!(middle.call(req.with_query(&query))); + let json: CrateList = ::json(&mut response); + + assert_eq!(json.crates[0].name, krate_not_owned_by_team.name); + assert_eq!(json.crates.len(), 1); + + let query = format!("team_id={}", team.id); + let mut response = ok_resp!(middle.call(req.with_query(&query))); + + let json: CrateList = ::json(&mut response); + assert_eq!(json.crates.len(), 1); + assert_eq!(json.crates[0].name, krate_owned_by_team.name); +} + +/* Given a crate owned by both a team and a user, check that the + JSON returned by the /owner_team route and /owner_user route + contains the correct kind of owner + + Note that in this case function new_team must take a team name + of form github:org_name:team_name as that is the format + EncodableOwner::encodable is expecting +*/ +#[test] +fn check_ownership_one_crate() { + let (_b, app, middle) = ::app(); + + let (team, user) = { + let conn = app.diesel_database.get().unwrap(); + let u = ::new_user("user_cat").create_or_update(&conn).unwrap(); + let t = ::new_team("github:test_org:team_sloth") + .create_or_update(&conn) + .unwrap(); + let krate = ::CrateBuilder::new("best_crate", u.id).expect_build(&conn); + ::add_team_to_crate(&t, &krate, &u, &conn).unwrap(); + (t, u) + }; + + let mut req = ::req( + app.clone(), + Method::Get, + "/api/v1/crates/best_crate/owner_team", + ); + let mut response = ok_resp!(middle.call(&mut req)); + let json: TeamResponse = ::json(&mut response); + + assert_eq!(json.teams[0].kind, "team"); + assert_eq!(json.teams[0].name, team.name); + + let mut req = ::req( + app.clone(), + Method::Get, + "/api/v1/crates/best_crate/owner_user", + ); + let mut response = ok_resp!(middle.call(&mut req)); + let json: UserResponse = ::json(&mut response); + + assert_eq!(json.users[0].kind, "user"); + assert_eq!(json.users[0].name, user.name); +} diff --git a/src/user/mod.rs b/src/user/mod.rs index d587577538f..88202918653 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -16,6 +16,8 @@ use util::errors::NotFound; use util::{RequestUtils, CargoResult, internal, ChainError, human}; use version::EncodableVersion; use {http, Model, Version}; +use owner::{Owner, OwnerKind, CrateOwner}; +use krate::Crate; pub use self::middleware::{Middleware, RequestUser}; @@ -160,6 +162,19 @@ impl User { })?)) } + pub fn owning(krate: &Crate, conn: &PgConnection) -> CargoResult> { + let base_query = CrateOwner::belonging_to(krate).filter(crate_owners::deleted.eq(false)); + let users = base_query + .inner_join(users::table) + .select(users::all_columns) + .filter(crate_owners::owner_kind.eq(OwnerKind::User as i32)) + .load(conn)? + .into_iter() + .map(Owner::User); + + Ok(users.collect()) + } + /// Converts this `User` model into an `EncodableUser` for JSON serialization. pub fn encodable(self) -> EncodableUser { let User { @@ -373,6 +388,22 @@ pub fn show(req: &mut Request) -> CargoResult { Ok(req.json(&R { user: user.encodable() })) } +/// Handles the `GET /teams/:team_id` route. +pub fn show_team(req: &mut Request) -> CargoResult { + use self::teams::dsl::{teams, login}; + use owner::Team; + use owner::EncodableTeam; + + let name = &req.params()["team_id"]; + let conn = req.db_conn()?; + let team = teams.filter(login.eq(name)).first::(&*conn)?; + + #[derive(RustcEncodable)] + struct R { + team: EncodableTeam, + } + Ok(req.json(&R { team: team.encodable() })) +} /// Handles the `GET /me/updates` route. pub fn updates(req: &mut Request) -> CargoResult { diff --git a/tests/acceptance/crate-test.js b/tests/acceptance/crate-test.js index 47bad3f728c..7aa540b2673 100644 --- a/tests/acceptance/crate-test.js +++ b/tests/acceptance/crate-test.js @@ -43,3 +43,41 @@ test('navigating to the reverse dependencies page', function(assert) { hasText(assert, $revDep, 'unicorn-rpc'); }); }); + +test('navigating to a user page', function(assert) { + visit('/crates/nanomsg'); + click('.owners li:last a'); + + andThen(function() { + assert.equal(currentURL(), '/users/blabaere'); + hasText(assert, '#crates-heading h1', 'thehydroimpulse'); + }); +}); + +test('navigating to a team page', function(assert) { + visit('/crates/nanomsg'); + click('.owners li:first a '); + + andThen(function() { + assert.equal(currentURL(), '/teams/github:org:thehydroimpulse'); + hasText(assert, '.team-info h2', 'thehydroimpulseteam'); + }); +}); + +test('crates having user-owners', function(assert) { + visit('/crates/nanomsg'); + + andThen(function() { + findWithAssert('ul.owners li:first a[href="/teams/github:org:thehydroimpulse"] img[src="https://avatars.githubusercontent.com/u/565790?v=3&s=64"]'); + assert.equal(find('ul.owners li').length, 4); + }); +}); + +test('crates having team-owners', function(assert) { + visit('/crates/nanomsg'); + + andThen(function() { + findWithAssert('ul.owners li:first a[href="/teams/github:org:thehydroimpulse"]'); + assert.equal(find('ul.owners li').length, 4); + }); +}); diff --git a/tests/acceptance/team-page-test.js b/tests/acceptance/team-page-test.js new file mode 100644 index 00000000000..9e2a164fa0c --- /dev/null +++ b/tests/acceptance/team-page-test.js @@ -0,0 +1,41 @@ +import { test } from 'qunit'; +import moduleForAcceptance from 'cargo/tests/helpers/module-for-acceptance'; + +moduleForAcceptance('Acceptance | team page'); + +test('has team organization display', function(assert) { + visit('/teams/github:org:thehydroimpulse'); + + andThen(function() { + hasText(assert, '.team-info h1', 'org_test'); + hasText(assert, '.team-info h2', 'thehydroimpulseteam'); + }); +}); + +test('has link to github in team header', function(assert) { + visit('/teams/github:org:thehydroimpulse'); + + andThen(function() { + const $githubLink = findWithAssert('.info a'); + assert.equal($githubLink.attr('href').trim(), 'https://github.com/thehydroimpulse'); + }); + +}); + +test('github link has image in team header', function(assert) { + visit('/teams/github:org:thehydroimpulse'); + + andThen(function() { + const $githubImg = findWithAssert('.info a img'); + assert.equal($githubImg.attr('src').trim(), '/assets/GitHub-Mark-32px.png'); + }); +}); + +test('team organization details has github profile icon', function(assert) { + visit('/teams/github:org:thehydroimpulse'); + + andThen(function() { + const $githubProfileImg = findWithAssert('.info img'); + assert.equal($githubProfileImg.attr('src').trim(), 'https://avatars.githubusercontent.com/u/565790?v=3&s=170'); + }); +}); diff --git a/tests/acceptance/user-page-test.js b/tests/acceptance/user-page-test.js new file mode 100644 index 00000000000..4d8ea96247e --- /dev/null +++ b/tests/acceptance/user-page-test.js @@ -0,0 +1,39 @@ +import { test } from 'qunit'; +import moduleForAcceptance from 'cargo/tests/helpers/module-for-acceptance'; + +moduleForAcceptance('Acceptance | user page'); + +test('has user display', function(assert) { + visit('/users/thehydroimpulse'); + + andThen(function() { + hasText(assert, '#crates-heading h1', 'thehydroimpulse'); + }); +}); + +test('has link to github in user header', function(assert) { + visit('/users/thehydroimpulse'); + + andThen(function() { + const $githubLink = findWithAssert('#crates-heading a'); + assert.equal($githubLink.attr('href').trim(), 'https://github.com/thehydroimpulse'); + }); +}); + +test('github link has image in user header', function(assert) { + visit('/users/thehydroimpulse'); + + andThen(function() { + const $githubImg = findWithAssert('#crates-heading a img'); + assert.equal($githubImg.attr('src').trim(), '/assets/GitHub-Mark-32px.png'); + }); +}); + +test('user details has github profile icon', function(assert) { + visit('/users/thehydroimpulse'); + + andThen(function() { + const $githubProfileImg = findWithAssert('#crates-heading img'); + assert.equal($githubProfileImg.attr('src').trim(), 'https://avatars.githubusercontent.com/u/565790?v=3&s=170'); + }); +});